# orchestrator-host.nix — the host contract that nix/modules/cc-ci.nix (the orchestrator's # loops/timers) silently assumes, made explicit and reusable: the `loops` user the agents run as, # the standalone opencode CLI, the shared opencode web server and its basic-auth web UI, # nix-ld so foreign binaries run on NixOS, and the tool set agents reach for. # # Exported from flake.nix as `nixosModules.orchestrator-host`. A host imports this together with # `nixosModules.cc-ci-orchestrator`; the combined CI-server + orchestrator host (`#cc-ci`) also # imports recipe-maintainers/cc-ci's `nixosModules.cc-ci-server`. # # History: until 2026-09 this lived (twice, drifting) in nix/hosts/cc-ci-orchestrator-hetzner/ # configuration.nix here and in notplants-nix's hosts/notplants-orchestrator/configuration.nix, # the shared agent box that also ran lichen + project-orchestrator. The cc-ci half moved to its # own host; this file is that half. { config, lib, pkgs, ... }: let cfg = config.cc-ci-orchestrator; in { options.cc-ci-orchestrator = { ciSshHost = lib.mkOption { type = lib.types.str; default = "127.0.0.1"; example = "100.95.31.88"; description = '' Where `ssh cc-ci` (used by every skill and script that drives the CI server) connects to, as root with ~loops/.ssh/cc-ci-root-ed25519. On the combined host the CI server IS this machine, so the default is loopback; a standalone orchestrator points it at the CI server's tailnet address. ''; }; opencodeUiBackendPort = lib.mkOption { type = lib.types.port; default = 8090; # not 8080: acme-dns's local API has it on the combined host description = '' Plain-HTTP port nginx listens on for the opencode UI, reachable ONLY from the docker bridge (firewall rule on docker_gwbridge). Traefik — which owns the public 443 on the combined host — terminates TLS for opencodeUiHost and forwards here; nginx adds the basic auth and logs failures for fail2ban with the real client IP. ''; }; opencodeUiTraefikNetwork = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "proxy"; description = '' Name of the swarm overlay network the cc-ci traefik watches (cc-ci's swarm.nix creates `proxy`). When set, a one-container swarm stack `opencode-ui` (a socat TCP relay to nginx on the docker bridge) is deployed with traefik labels routing opencodeUiHost on the `web-secure` entrypoint — the same label mechanism every cc-ci service and recipe uses, so it coexists with the traefik recipe's own file provider (the wildcard cert and the `security` middleware live there; switching traefik to a file *directory* replaces that file and takes every front door down — learned 2026-09-07). null = no route. ''; }; opencodeUiHost = lib.mkOption { type = lib.types.str; default = "oc.commoninternet.net"; description = "nginx server_name for the opencode web UI (TLS + basic auth)."; }; }; config = { # ---- the loops user ------------------------------------------------------------------- # claude sessions run as non-root (--dangerously-skip-permissions is refused for root). users.users.loops = { isNormalUser = true; uid = 1000; # fixed: workspace files are rsynced between hosts by uid home = "/home/loops"; shell = pkgs.bash; extraGroups = [ "wheel" "docker" ]; }; security.sudo.wheelNeedsPassword = false; security.sudo.extraRules = [{ users = [ "loops" ]; commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }]; }]; # /home/loops/.local/bin holds the standalone opencode binary; it must be first on every PATH # (interactive shells, tmux, the systemd units in cc-ci.nix prepend it too). environment.variables.PATH = lib.mkForce "/home/loops/.local/bin:/run/current-system/sw/bin:/run/wrappers/bin:/usr/bin:/bin"; # ---- nix-ld: the standalone opencode CLI is a foreign dynamic ELF binary -------------------- programs.nix-ld.enable = true; programs.nix-ld.libraries = with pkgs; [ stdenv.cc.cc.lib zlib openssl curl glibc ]; # ---- the toolbox every agent on this box gets ---------------------------------------- # Bar for adding something: an agent doing ordinary work would otherwise waste a turn # discovering it is absent. environment.systemPackages = with pkgs; [ git tmux python3 jq curl cacert gnused gawk coreutils gnugrep findutils util-linux nettools openssh age sops ssh-to-age wget gnutar gzip unzip zip xz ripgrep fd tree file less which procps psmisc htop lsof strace ncdu dnsutils socat netcat-gnu iproute2 iputils openssl gnumake gcc pkg-config yq-go diffutils patch rsync bubblewrap ]; # ---- ssh config for the loops user: `ssh cc-ci` = the CI server (root) ----------------- # Written only if absent so a manual customisation survives rebuilds. system.activationScripts.loopsSshConfig = '' mkdir -p /home/loops/.ssh && chown loops:users /home/loops/.ssh && chmod 700 /home/loops/.ssh if [ ! -f /home/loops/.ssh/config ]; then cat > /home/loops/.ssh/config <<'SSHCFG' Host cc-ci HostName ${cfg.ciSshHost} User root IdentityFile /home/loops/.ssh/cc-ci-root-ed25519 IdentitiesOnly yes StrictHostKeyChecking accept-new ServerAliveInterval 30 Host git.autonomic.zone HostName git.autonomic.zone Port 2222 User git IdentityFile /home/loops/.ssh/autonomic-bot-gitea-ed25519 IdentitiesOnly yes SSHCFG chmod 600 /home/loops/.ssh/config chown loops:users /home/loops/.ssh/config fi ''; # ---- standalone CLIs (idempotent installers; re-run on every activation, no-op if present) -- # No Claude Code on this host (operator 2026-09-07): the orchestrator and the weekly upgrader # are opencode agents; Claude sessions run on the notplants-orchestrator box and reach this # host over ssh. systemd.services.opencode-install = { description = "Install opencode CLI for loops user (idempotent)"; wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; User = "loops"; Group = "users"; }; environment = { HOME = "/home/loops"; }; path = [ pkgs.curl pkgs.bash pkgs.coreutils pkgs.gnutar pkgs.gzip pkgs.unzip ]; script = '' if [ ! -x "$HOME/.local/bin/opencode" ]; then echo "installing opencode CLI for loops user..." curl -fsSL https://opencode.ai/install | bash || echo "install failed — retry on next activation" # The installer puts the binary in ~/.opencode/bin; every unit here expects ~/.local/bin. if [ -x "$HOME/.opencode/bin/opencode" ]; then mkdir -p "$HOME/.local/bin" && ln -sfn "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" fi fi ''; }; # ---- opencode web server: one shared instance the opencode-backed agents attach to ------- # Provider creds come from /srv/cc-ci/.testenv (out of band, see README). systemd.services.opencode-web = { description = "opencode web server for cc-ci agents"; wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" "opencode-install.service" ]; wants = [ "network-online.target" ]; serviceConfig = { Type = "simple"; User = "loops"; Group = "users"; WorkingDirectory = "/srv/cc-ci-orch/cc-ci"; EnvironmentFile = [ "-/srv/cc-ci/cc-ci/.env.public" "/srv/cc-ci/.testenv" ]; ExecStartPre = "${pkgs.coreutils}/bin/rm -rf /tmp/opencode"; ExecStart = "/home/loops/.local/bin/opencode serve --hostname 127.0.0.1 --port 4096"; Restart = "on-failure"; RestartSec = "5s"; }; environment = { HOME = "/home/loops"; PATH = lib.mkForce "/run/wrappers/bin:/home/loops/.local/bin:/run/current-system/sw/bin:/usr/bin:/bin:/etc/profiles/per-user/loops/bin:/nix/var/nix/profiles/default/bin"; }; path = [ pkgs.bash pkgs.coreutils pkgs.git pkgs.python3 pkgs.openssh pkgs.tmux pkgs.nettools ]; }; # ---- nginx: basic auth for the opencode UI, behind traefik ----------------------------- # Traefik (public 443, the CI wildcard cert) routes opencodeUiHost to this plain-HTTP vhost # on the docker bridge address. nginx enforces HTTP basic auth (the opencode web UI has no # auth of its own and can drive agent sessions), and — via real_ip from traefik's # X-Forwarded-For — logs the CLIENT address on a 401, which is what the fail2ban jail bans. # The htpasswd is created out of band (a store path would be world readable); nginx FAILS TO # START without it, and its config check runs as the nginx user: # /etc/nginx/oc-htpasswd root:nginx 0640 (`oc:`; plaintext kept in /secrets) # Rotate with: printf 'oc:%s\n' "$(mkpasswd -m bcrypt "$P")" > /etc/nginx/oc-htpasswd && systemctl reload nginx services.nginx = { enable = true; recommendedProxySettings = true; virtualHosts.${cfg.opencodeUiHost} = { listen = [ { addr = "0.0.0.0"; port = cfg.opencodeUiBackendPort; } ]; basicAuthFile = "/etc/nginx/oc-htpasswd"; extraConfig = '' # traefik sits on the docker networks (ingress 10.0.0.0/24, gwbridge 172.18.0.0/16) set_real_ip_from 172.16.0.0/12; set_real_ip_from 10.0.0.0/8; real_ip_header X-Forwarded-For; ''; locations."/" = { proxyPass = "http://127.0.0.1:4096"; proxyWebsockets = true; }; }; }; # Only docker's bridge may reach the plain-HTTP backend; the public interface stays closed. networking.firewall.interfaces.docker_gwbridge.allowedTCPPorts = [ cfg.opencodeUiBackendPort ]; # The traefik side of the route: a swarm service carrying the router labels. It is a plain # TCP relay (socat) from the overlay network to nginx on the docker bridge; traefik's # X-Forwarded-For passes through untouched, which is what nginx's real_ip reads. systemd.services.opencode-ui-route = lib.mkIf (cfg.opencodeUiTraefikNetwork != null) { description = "swarm stack opencode-ui: traefik labels ${cfg.opencodeUiHost} -> nginx basic auth"; wantedBy = [ "multi-user.target" ]; after = [ "deploy-proxy.service" "docker.service" "nginx.service" ]; wants = [ "deploy-proxy.service" ]; path = [ pkgs.docker ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; script = '' docker stack deploy --detach=true -c ${pkgs.writeText "opencode-ui-stack.yml" '' # Deployed by opencode-ui-route.service (nix/modules/orchestrator-host.nix). Do not edit. version: "3.8" services: relay: image: alpine/socat:1.8.0.3 command: ["TCP-LISTEN:${toString cfg.opencodeUiBackendPort},fork,reuseaddr", "TCP:172.18.0.1:${toString cfg.opencodeUiBackendPort}"] networks: [ ${cfg.opencodeUiTraefikNetwork} ] deploy: replicas: 1 labels: - "traefik.enable=true" - "traefik.http.routers.opencode-ui.rule=Host(`${cfg.opencodeUiHost}`)" - "traefik.http.routers.opencode-ui.entrypoints=web-secure" - "traefik.http.routers.opencode-ui.tls=true" - "traefik.http.services.opencode-ui.loadbalancer.server.port=${toString cfg.opencodeUiBackendPort}" networks: ${cfg.opencodeUiTraefikNetwork}: external: true ''} opencode-ui ''; }; }; }