cc-ci host: no tailscale; fail2ban; opencode UI public on 8443 with the LE wildcard

Operator 2026-09-07: the combined host stays off the tailnet. ssh is
key-only (password + keyboard-interactive auth off), the opencode UI is
served as oc.ci.commoninternet.net on 8443 — TLS from the CI server's
*.ci.commoninternet.net ACME cert (new orchestrator-host option
opencodeUiAcmeHost; nginx joins the acme group) behind the existing basic
auth — and fail2ban watches sshd plus nginx's 401s (nginx-http-auth jail).
tailscaled ordering dropped from the orchestrator units. README updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqkQq3CDmFWcQ7u1LzoyRz
This commit is contained in:
2026-09-07 20:45:59 +00:00
co-authored by Claude Fable 5.1
parent e5b0177c93
commit 8f7f125fc0
4 changed files with 86 additions and 44 deletions
+4 -4
View File
@@ -17,7 +17,7 @@
systemd.services.cc-ci-loops = {
description = "cc-ci Builder/Adversary loops + watchdog (launch.sh start)";
wantedBy = [ "multi-user.target" ]; # enabled after workspace staged (Hetzner cutover)
after = [ "network-online.target" "tailscaled.service" "claude-install.service" ];
after = [ "network-online.target" "claude-install.service" ];
wants = [ "network-online.target" ];
serviceConfig = {
# KillMode=process: this unit only LAUNCHES the tmux server, it does not own it. With the
@@ -58,7 +58,7 @@
systemd.services.cc-ci-orchestrator = {
description = "cc-ci orchestrator (operator steering session) agents.py up orchestrator";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" "tailscaled.service" "opencode-web.service" ];
after = [ "network-online.target" "opencode-web.service" ];
wants = [ "network-online.target" ];
serviceConfig = {
# KillMode=process: see the note on cc-ci-loops — a rebuild that merely touches this unit
@@ -85,7 +85,7 @@
# wantedBy multi-user.target) so it never runs on boot/activation — only on the schedule.
systemd.services.cc-ci-upgrade-all = {
description = "cc-ci weekly /upgrade-all run (recipe upgrade survey + PRs, never merges)";
after = [ "network-online.target" "tailscaled.service" "claude-install.service" ];
after = [ "network-online.target" "claude-install.service" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot"; # launch-upgrader.py spawns the cc-ci-upgrader tmux session and returns
@@ -129,7 +129,7 @@
# short-lived glm-5.2 agent that diagnoses the blockage and drives the run to a clean DONE.
systemd.services.cc-ci-upgrade-supervisor = {
description = "cc-ci hourly weekly-run supervisor (glm-5.2 drives a stalled /upgrade-all to completion)";
after = [ "network-online.target" "tailscaled.service" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot"; # launch-supervisor.py check: gate now, spawn the agent into tmux, return
+31 -11
View File
@@ -34,15 +34,26 @@ in
default = 8443;
description = ''
TLS port of the nginx front door for the opencode web UI. Not 443: on the combined host
Traefik (docker swarm) owns 80/443. The port is not opened in the firewall, so it is
reachable only over the trusted tailscale interface.
Traefik (docker swarm) owns 80/443. The host decides whether to open it in the firewall.
'';
};
opencodeUiHost = lib.mkOption {
type = lib.types.str;
default = "oc.commoninternet.net";
description = "nginx server_name for the opencode web UI (self-signed, basic auth).";
description = "nginx server_name for the opencode web UI (TLS + basic auth).";
};
opencodeUiAcmeHost = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "ci.commoninternet.net";
description = ''
Name of a `security.acme.certs` entry whose certificate covers opencodeUiHost (the
combined host has the *.ci.commoninternet.net wildcard). When set, nginx serves that
certificate; when null, the self-signed pair /etc/nginx/oc-selfsigned.{crt,key} staged
out of band is used.
'';
};
};
@@ -158,7 +169,7 @@ SSHCFG
systemd.services.opencode-web = {
description = "opencode web server for cc-ci agents";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" "tailscaled.service" "opencode-install.service" ];
after = [ "network-online.target" "opencode-install.service" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "simple";
@@ -177,12 +188,20 @@ SSHCFG
path = [ pkgs.bash pkgs.coreutils pkgs.git pkgs.python3 pkgs.openssh pkgs.tmux pkgs.nettools ];
};
# ---- tailnet-only nginx front door for the opencode UI -------------------------------
# Self-signed cert + basic auth, both created out of band (a store path would be world
# readable) — see README "Secrets to stage". nginx FAILS TO START if they are missing.
# /etc/nginx/oc-selfsigned.crt root:nginx 0644
# ---- nginx front door for the opencode UI --------------------------------------------
# TLS (LE via opencodeUiAcmeHost, else the self-signed pair below) + HTTP basic auth. The
# opencode web UI has no auth of its own and can drive agent sessions, so the htpasswd is
# mandatory. Files created out of band (a store path would be world readable) — nginx
# FAILS TO START without them, and its config check runs as the nginx user:
# /etc/nginx/oc-htpasswd root:nginx 0640 (`oc:<bcrypt>`; plaintext kept in /secrets)
# /etc/nginx/oc-selfsigned.crt root:nginx 0644 (only when opencodeUiAcmeHost is null)
# /etc/nginx/oc-selfsigned.key root:nginx 0640
# /etc/nginx/oc-htpasswd root:nginx 0640 (`oc:<bcrypt>`; plaintext in /secrets)
# Rotate the password with:
# printf 'oc:%s\n' "$(mkpasswd -m bcrypt "$P")" > /etc/nginx/oc-htpasswd && systemctl reload nginx
# nginx must be able to read the ACME-issued key (the acme group owns it).
users.users.nginx.extraGroups = lib.mkIf (cfg.opencodeUiAcmeHost != null)
[ config.security.acme.certs.${cfg.opencodeUiAcmeHost}.group ];
services.nginx = {
enable = true;
recommendedProxySettings = true;
@@ -190,8 +209,9 @@ SSHCFG
listen = [ { addr = "0.0.0.0"; port = cfg.opencodeUiPort; ssl = true; } ];
# onlySSL flags the vhost as SSL so the module renders ssl_certificate for the listener.
onlySSL = true;
sslCertificate = "/etc/nginx/oc-selfsigned.crt";
sslCertificateKey = "/etc/nginx/oc-selfsigned.key";
useACMEHost = cfg.opencodeUiAcmeHost;
sslCertificate = lib.mkIf (cfg.opencodeUiAcmeHost == null) "/etc/nginx/oc-selfsigned.crt";
sslCertificateKey = lib.mkIf (cfg.opencodeUiAcmeHost == null) "/etc/nginx/oc-selfsigned.key";
basicAuthFile = "/etc/nginx/oc-htpasswd";
locations."/" = {
proxyPass = "http://127.0.0.1:4096";