Operator: standard 443, routed by domain. Traefik already owns 443 with the *.ci.commoninternet.net cert, so opencode-ui-route.service drops a dynamic config into the traefik stack's watched file-providers volume routing oc.ci.commoninternet.net -> http://172.18.0.1:8080, where nginx (reachable only on docker_gwbridge) enforces the basic auth and logs 401s with the real client IP (real_ip from X-Forwarded-For). The fail2ban nginx jail bans in DOCKER-USER, since that traffic is forwarded, not INPUT. 8443 and the ACME-host variant are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqkQq3CDmFWcQ7u1LzoyRz
251 lines
11 KiB
Nix
251 lines
11 KiB
Nix
# 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 claude/opencode CLIs, the shared opencode web server and its tailnet-only 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 = 8080;
|
|
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.
|
|
'';
|
|
};
|
|
|
|
opencodeUiTraefikProvidersDir = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
example = "/var/lib/docker/volumes/traefik_ci_commoninternet_net_file-providers/_data";
|
|
description = ''
|
|
Host path of the traefik stack's file-providers directory (watched by traefik). When set,
|
|
a dynamic-config file routing opencodeUiHost on the `web-secure` entrypoint to nginx is
|
|
written there after deploy-proxy. null = no traefik route (serve some other way).
|
|
'';
|
|
};
|
|
|
|
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 claude + opencode binaries; 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 Claude Code / opencode CLIs are foreign dynamic ELF binaries ---
|
|
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
|
|
|
|
Host tangled.org
|
|
IdentityFile /home/loops/.ssh/tangled-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) --
|
|
systemd.services.claude-install = {
|
|
description = "Install Claude Code 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 ];
|
|
script = ''
|
|
if [ ! -x "$HOME/.local/bin/claude" ]; then
|
|
echo "installing Claude Code CLI for loops user..."
|
|
curl -fsSL https://claude.ai/install.sh | bash || echo "install failed — retry on next activation"
|
|
fi
|
|
'';
|
|
};
|
|
|
|
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:<bcrypt>`; 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 dynamic-config file in the stack's watched providers dir.
|
|
systemd.services.opencode-ui-route = lib.mkIf (cfg.opencodeUiTraefikProvidersDir != null) {
|
|
description = "traefik route: ${cfg.opencodeUiHost} -> nginx (opencode UI basic auth)";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "deploy-proxy.service" "docker.service" ];
|
|
wants = [ "deploy-proxy.service" ];
|
|
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; };
|
|
script = ''
|
|
install -d -m 0755 "${cfg.opencodeUiTraefikProvidersDir}"
|
|
install -m 0644 ${pkgs.writeText "opencode-ui.yml" ''
|
|
# Written by opencode-ui-route.service (nix/modules/orchestrator-host.nix). Do not edit.
|
|
http:
|
|
routers:
|
|
opencode-ui:
|
|
rule: Host(`${cfg.opencodeUiHost}`)
|
|
entryPoints: [web-secure]
|
|
service: opencode-ui
|
|
tls: {}
|
|
services:
|
|
opencode-ui:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://172.18.0.1:${toString cfg.opencodeUiBackendPort}
|
|
''} "${cfg.opencodeUiTraefikProvidersDir}/opencode-ui.yml"
|
|
'';
|
|
};
|
|
};
|
|
}
|