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:
@@ -1,7 +1,7 @@
|
||||
# cc-ci — ONE Hetzner Cloud host running both the cc-ci CI server and the cc-ci orchestrator.
|
||||
#
|
||||
# This file is only what is physical or identity about the machine: hardware, networking, the
|
||||
# tailscale node, root SSH keys, swap, stateVersion. Everything functional comes from modules:
|
||||
# This file is only what is physical or identity about the machine: hardware, networking,
|
||||
# root SSH keys, firewall + fail2ban, swap, stateVersion. Everything functional comes from modules:
|
||||
# cc-ci.nixosModules.cc-ci-server recipe-maintainers/cc-ci — swarm, traefik, drone,
|
||||
# runner, bridge, dashboard, reports, acme-dns, harness
|
||||
# self.nixosModules.cc-ci-orchestrator nix/modules/cc-ci.nix — loops, orchestrator, timers
|
||||
@@ -27,13 +27,13 @@
|
||||
# ---- orchestrator identity --------------------------------------------------------------
|
||||
# The CI server is this very host, so `ssh cc-ci` goes to loopback (the module default).
|
||||
cc-ci-orchestrator.ciSshHost = "127.0.0.1";
|
||||
# The opencode UI rides the CI server's Let's Encrypt wildcard (*.ci.commoninternet.net).
|
||||
cc-ci-orchestrator.opencodeUiHost = "oc.ci.commoninternet.net";
|
||||
cc-ci-orchestrator.opencodeUiAcmeHost = "ci.commoninternet.net";
|
||||
|
||||
# ---- tailscale — auth key staged out of band at /etc/ts-auth-key -----------------------
|
||||
services.tailscale = {
|
||||
enable = true;
|
||||
authKeyFile = "/etc/ts-auth-key";
|
||||
extraUpFlags = [ "--hostname=cc-ci" ];
|
||||
};
|
||||
# ---- no tailscale on this host (operator 2026-09-07) --------------------------------------
|
||||
# Everything is reached over the public IP: ssh (keys only), the CI front doors via traefik,
|
||||
# and the opencode UI on 8443 behind TLS + basic auth. fail2ban below guards the two logins.
|
||||
|
||||
# ---- ssh ----------------------------------------------------------------------------------
|
||||
services.openssh = {
|
||||
@@ -46,15 +46,35 @@
|
||||
# The loops user can also be reached directly (same keys) — handy for rsync of its workspace.
|
||||
users.users.loops.openssh.authorizedKeys.keys =
|
||||
builtins.filter (s: s != "") (lib.splitString "\n" (builtins.readFile ./ssh-keys));
|
||||
services.openssh.settings.PasswordAuthentication = false;
|
||||
services.openssh.settings.KbdInteractiveAuthentication = false;
|
||||
|
||||
# ---- firewall -------------------------------------------------------------------------------
|
||||
# 80/443 (traefik) and 53 (acme-dns) are opened by the cc-ci-server module. The tailscale
|
||||
# interface is trusted, which is what makes the opencode UI on 8443 tailnet-only.
|
||||
# 80/443 (traefik) and 53 (acme-dns) are opened by the cc-ci-server module. 8443 is the
|
||||
# opencode UI (nginx, LE cert, basic auth — see orchestrator-host.nix).
|
||||
networking.firewall = {
|
||||
enable = true;
|
||||
trustedInterfaces = [ "tailscale0" ];
|
||||
allowedTCPPorts = [ 22 ];
|
||||
allowedTCPPorts = [ 22 8443 ];
|
||||
};
|
||||
|
||||
# ---- fail2ban: sshd (password auth is off, this stops the log noise and slow brute force) and
|
||||
# the opencode UI's basic auth (nginx logs 401s to its error log; the built-in
|
||||
# nginx-http-auth filter matches them).
|
||||
services.fail2ban = {
|
||||
enable = true;
|
||||
maxretry = 5;
|
||||
bantime = "1h";
|
||||
bantime-increment = { enable = true; maxtime = "48h"; factor = "4"; };
|
||||
ignoreIP = [ "127.0.0.0/8" "::1" ];
|
||||
jails.nginx-http-auth.settings = {
|
||||
enabled = true;
|
||||
filter = "nginx-http-auth";
|
||||
logpath = "/var/log/nginx/error.log";
|
||||
backend = "auto";
|
||||
port = "8443";
|
||||
};
|
||||
};
|
||||
|
||||
networking.nameservers = [ "1.1.1.1" "8.8.8.8" ];
|
||||
|
||||
# ---- memory: 8 GB RAM shared by the swarm (recipe deploys) and 3–6 agent sessions ---------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user