nix: one Hetzner host for the CI server + the orchestrator (#cc-ci)
The orchestrator's flake now builds the machine it shares with the cc-ci CI
server: `nixosConfigurations.cc-ci` composes cc-ci's nixosModules.cc-ci-server
(new flake input, nixpkgs + sops-nix follow ours), this repo's orchestrator
module (nix/modules/cc-ci.nix, exported as cc-ci-orchestrator, `cc-ci` kept
as an alias for notplants-nix) and the new nix/modules/orchestrator-host.nix
— the host contract those units always assumed (loops user, claude/opencode
CLIs, opencode web server + tailnet-only UI on 8443 since traefik owns
80/443, nix-ld, tool set, `ssh cc-ci` → loopback).
nix/hosts/cc-ci/{hardware,networking}.nix are PROVISIONAL copies of the old
server's layout so the flake evaluates; they get replaced by the
nixos-infect output of 195.201.88.249.
README.md is the deploy guide (Hetzner Debian → nixos-infect → this flake →
staging → data restore → cutover). archive/ holds the retired Incus/Hetzner
orchestrator host configs, the old terraform and the migration plans;
references updated. cc-ci-plan/plan-cc-ci-combined-host.md is the working
plan for the move.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqkQq3CDmFWcQ7u1LzoyRz
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# cc-ci-orchestrator
|
||||
|
||||
> ⚠️ **HISTORICAL.** This README describes the retired **Incus VM** (`100.116.55.106`). The
|
||||
> orchestrator now runs on **Hetzner** — the live host config is
|
||||
> `nix/hosts/cc-ci-orchestrator-hetzner/configuration.nix`. See
|
||||
> `cc-ci-plan/plan-orchestrator-hetzner-migration.md` for the current setup. Kept for history.
|
||||
|
||||
NixOS config for the **`cc-ci-orchestrator`** Incus VM (b1, project `terraform-ci`, tailnet
|
||||
`100.116.55.106`) — the reboot-resilient host for the cc-ci Builder/Adversary loops + watchdog +
|
||||
orchestrator session, moved off the unstable 905 MiB Pi.
|
||||
|
||||
See `cc-ci-plan/plan-orchestrator-migration.md` for the full migration.
|
||||
|
||||
## Files
|
||||
- `configuration.nix` — the VM's NixOS config (channel-based, `nixos-24.11`). Deployed to
|
||||
`/etc/nixos/configuration.nix` on the VM. Provides: nix-ld (so the standalone Claude Code Bun binary
|
||||
runs), tmux/git/python/jq + tools, a 4 GB swapfile, direct ssh to cc-ci (the VM is a tailnet peer —
|
||||
no SOCKS proxy needed, unlike the Pi), an idempotent `claude-install` oneshot, and the
|
||||
`cc-ci-loops` supervisor service (defined, **enabled in Phase D** once the workspace is staged).
|
||||
|
||||
## Deploy (until this is wired to a flake/auto-pull)
|
||||
```
|
||||
# copy configuration.nix to the VM, then:
|
||||
ssh cc-ci-orchestrator 'nixos-rebuild switch' # or run detached: see below
|
||||
```
|
||||
Over the (currently flaky) Pi→VM link, run the rebuild **detached** on the VM so an ssh/proxy drop
|
||||
doesn't abort it, e.g. `systemd-run --unit=orch-rebuild --collect nixos-rebuild switch` then poll
|
||||
`journalctl -u orch-rebuild`.
|
||||
|
||||
## Status
|
||||
- Phase A: VM created (2 GB / 2 vCPU / 30 GB), on tailnet, ssh-able. ✅
|
||||
- Phase B: this config (DRAFT) — nix-ld/claude validation pending on the VM.
|
||||
- Operator step pending (Phase C): `claude auth login` on the VM (device-code; can't be scripted).
|
||||
- Secrets to stage (Phase C, out-of-band): `/srv/cc-ci/.testenv`, `~/.ssh/cc-ci-root-ed25519`,
|
||||
Incus mTLS certs, the sops master age key.
|
||||
@@ -0,0 +1,135 @@
|
||||
# atproto-likes — the "most-liked accounts" web UI, run as a docker-compose stack
|
||||
#
|
||||
# ⚠️ COPY. Canonical source:
|
||||
# /srv/project-orchestrator/projects/notplants-atproto/nix/atproto-likes.nix
|
||||
# Pure evaluation cannot import a path outside the flake tree. Re-copy after editing.
|
||||
#
|
||||
# Host-specific context that is deliberately NOT in the public project repo:
|
||||
# - the domain is fronted by the gateway (143.244.213.108); it forwards everything here
|
||||
# - this vhost lists 0.0.0.0 only so it never shares an nginx address group with
|
||||
# oc.commoninternet.net (opencode, on the tailscale address, no auth of its own)
|
||||
# - /srv/project-orchestrator/projects/notplants-atproto/tools/check-exposure.sh asserts
|
||||
# what this host serves publicly; run it after any nginx change
|
||||
# behind the host's nginx.
|
||||
#
|
||||
# Import this from the host configuration:
|
||||
# imports = [ /srv/project-orchestrator/projects/notplants-atproto/nix/atproto-likes.nix ];
|
||||
#
|
||||
# What it sets up:
|
||||
# * the Docker daemon
|
||||
# * a systemd unit that builds and runs docker-compose.yml from the project checkout
|
||||
# * an nginx vhost for atproto.commoninternet.net with a Let's Encrypt cert,
|
||||
# proxying to the container on 127.0.0.1:8731
|
||||
# * ports 80/443 opened in the firewall
|
||||
#
|
||||
# DNS / TLS: point the domain's A record at this host (or at a reverse proxy that
|
||||
# forwards to it). ACME HTTP-01 needs the challenge to reach this nginx, so if a
|
||||
# proxy fronts the domain it must forward /.well-known/acme-challenge/ through.
|
||||
# Until a certificate is issued, NixOS installs a self-signed placeholder so nginx
|
||||
# still starts.
|
||||
#
|
||||
# A proxy in front should preserve the Host header (nginx routes by server_name),
|
||||
# pass Upgrade/Connection through (the loading page is a websocket) and allow a
|
||||
# long read timeout (a cold scan can run for minutes).
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
projectDir = "/srv/project-orchestrator/projects/notplants-atproto";
|
||||
domain = "atproto.commoninternet.net";
|
||||
hostPort = 8731; # must match the ports: mapping in docker-compose.yml
|
||||
compose = "${pkgs.docker-compose}/bin/docker-compose";
|
||||
in
|
||||
{
|
||||
virtualisation.docker = {
|
||||
enable = true;
|
||||
# Reclaim dangling images from repeated `--build` runs.
|
||||
autoPrune = { enable = true; dates = "weekly"; };
|
||||
};
|
||||
|
||||
# Build + run the compose stack. Type=oneshot with RemainAfterExit: compose
|
||||
# detaches, and the containers' own restart policy keeps them alive.
|
||||
systemd.services.atproto-likes = {
|
||||
description = "atproto-likes — most-liked-accounts web UI (docker compose)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "docker.service" "docker.socket" "network-online.target" ];
|
||||
requires = [ "docker.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
path = [ pkgs.docker pkgs.docker-compose ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
WorkingDirectory = projectDir;
|
||||
# A cold `--build` pulls python:3.12-slim and installs pip deps.
|
||||
TimeoutStartSec = "1800";
|
||||
# The container runs unprivileged as uid 10001 (see Dockerfile), but the
|
||||
# page cache is a host bind mount, so its ownership is the host's business.
|
||||
# Without this the app 500s on any actor whose cache files were written by
|
||||
# an earlier root-running container.
|
||||
ExecStartPre = [
|
||||
"${pkgs.coreutils}/bin/mkdir -p ${projectDir}/cache"
|
||||
"${pkgs.coreutils}/bin/chown -R 10001:10001 ${projectDir}/cache"
|
||||
];
|
||||
ExecStart = "${compose} up -d --build --remove-orphans";
|
||||
ExecStop = "${compose} down";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "30s";
|
||||
};
|
||||
};
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
recommendedProxySettings = true;
|
||||
virtualHosts.${domain} = {
|
||||
# addSSL, NOT forceSSL. If a reverse proxy fronts this domain and its
|
||||
# back-end hop is plain HTTP, a forced 301 to https://<domain>/ would
|
||||
# resolve straight back to that proxy — a redirect loop. Serving both
|
||||
# schemes lets whatever is in front decide where TLS terminates.
|
||||
addSSL = true;
|
||||
enableACME = true;
|
||||
# Listens on 0.0.0.0 only, and the reject-everything default server below
|
||||
# owns those addresses. nginx groups server blocks by the connection's local
|
||||
# address, so keeping this vhost on the public address alone means it never
|
||||
# shares an address group with anything else the host may serve on another
|
||||
# interface — no other service can be reached by sending this one an
|
||||
# unexpected Host header.
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString hostPort}";
|
||||
# The loading page streams scan progress over a websocket.
|
||||
proxyWebsockets = true;
|
||||
# A cold scan can run for minutes with the socket open; the default 60s
|
||||
# proxy read timeout would cut the loading page off mid-flower.
|
||||
extraConfig = ''
|
||||
proxy_read_timeout 1800s;
|
||||
proxy_send_timeout 1800s;
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# Strict default server for the public addresses. Without an explicit
|
||||
# default_server, nginx promotes the first server block in the group — so any
|
||||
# vhost added later silently becomes what answers unmatched, absent or spoofed
|
||||
# Host headers from the internet. This closes that door once: only names
|
||||
# deliberately served get a response.
|
||||
#
|
||||
# port 80 -> 444 (close the connection, no response at all)
|
||||
# port 443 -> ssl_reject_handshake, so an unknown SNI never even gets TLS
|
||||
virtualHosts."public-default-reject" = {
|
||||
default = true;
|
||||
serverName = null;
|
||||
rejectSSL = true;
|
||||
listen = [
|
||||
{ addr = "0.0.0.0"; port = 80; ssl = false; }
|
||||
{ addr = "0.0.0.0"; port = 443; ssl = true; }
|
||||
];
|
||||
extraConfig = "return 444;";
|
||||
};
|
||||
};
|
||||
|
||||
security.acme = {
|
||||
acceptTerms = true;
|
||||
defaults.email = "mfowler.email@protonmail.com"; # ACME contact
|
||||
};
|
||||
|
||||
# Public HTTP/HTTPS.
|
||||
networking.firewall.allowedTCPPorts = [ 80 443 ];
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
# cc-ci-orchestrator-hetzner — NixOS config for the Hetzner loops runtime host.
|
||||
#
|
||||
# Purpose: run the cc-ci Builder/Adversary/Watchdog loops + orchestrator/assistant sessions
|
||||
# on a Hetzner cpx11 (2 vCPU / 2 GB dedicated AMD / 40 GB NVMe), replacing the slow b1 Incus VM.
|
||||
#
|
||||
# Provision with terraform/ then converge with: nixos-rebuild switch --flake .#cc-ci-orchestrator-hetzner
|
||||
# See terraform/README.md for the full Stage 2 procedure.
|
||||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
# hardware.nix is the nixos-infect generated hardware-configuration.nix (see README Stage 2a).
|
||||
|
||||
# atproto-likes — the notplants-atproto "most-liked accounts" web UI, served at
|
||||
# atproto.commoninternet.net. Brings in Docker + a compose stack + an nginx vhost
|
||||
# with ACME, and opens 80/443 (previously only 22 was public). Canonical source of
|
||||
# the module is the project repo; ../atproto-likes.nix is a copy kept in this tree
|
||||
# because pure evaluation cannot import a path outside the flake.
|
||||
# cc-ci.nix holds everything that exists FOR cc-ci (the loops, the cc-ci orchestrator session,
|
||||
# the weekly recipe-upgrade run and its hourly supervisor). Split out 2026-08-20 so this host —
|
||||
# a general agent box that also serves unrelated projects — can be reasoned about separately
|
||||
# from cc-ci. Also exported as `nixosModules.cc-ci` from this repo's flake for other hosts.
|
||||
imports = [ ../../atproto-likes.nix ../../modules/cc-ci.nix ];
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings.PermitRootLogin = "yes";
|
||||
};
|
||||
|
||||
# Root SSH access — all keys from the current orchestrator VM's /root/.ssh/authorized_keys.
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOk8NaeBdPbS2gfUvbny8h0AkZlVjGYHzx4QPXSJ38gd claude@claude-vm"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJVlfoLBPseQ9fA9534KmRg2KWcksKZGzAJIpHJ2JpsI mfowler.email@protonmail.com"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAcyTGb/wVgdhg5oBCZZvBaR1RuUQRY/3WHnOQpNDCsp claude-cc-ci-sandbox@20260526"
|
||||
];
|
||||
networking.useDHCP = true;
|
||||
networking.nameservers = [ "1.1.1.1" "8.8.8.8" ];
|
||||
networking.firewall = {
|
||||
enable = true;
|
||||
trustedInterfaces = [ "tailscale0" ];
|
||||
# Port 80 open only on the tailscale interface (trusted) — nginx binds there for oc.commoninternet.net.
|
||||
allowedTCPPorts = [ 22 ];
|
||||
};
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
system.stateVersion = "24.11";
|
||||
|
||||
# Tailscale — auth key at /etc/ts-auth-key (placed manually in Stage 2, not in git).
|
||||
services.tailscale = {
|
||||
enable = true;
|
||||
authKeyFile = "/etc/ts-auth-key";
|
||||
extraUpFlags = [ "--hostname=cc-ci-orchestrator" ];
|
||||
};
|
||||
|
||||
# 4 GB disk swap — claude session memory safety net (2 GB RAM is tight for 3+ sessions).
|
||||
swapDevices = [ { device = "/swapfile"; size = 4096; } ];
|
||||
|
||||
# nix-ld — lets the standalone Claude Code CLI (foreign dynamic ELF / Bun) run on NixOS.
|
||||
programs.nix-ld.enable = true;
|
||||
programs.nix-ld.libraries = with pkgs; [
|
||||
stdenv.cc.cc.lib
|
||||
zlib
|
||||
openssl
|
||||
curl
|
||||
glibc
|
||||
];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
git tmux python3 jq curl cacert
|
||||
gnused gawk coreutils gnugrep findutils util-linux
|
||||
nettools openssh
|
||||
age sops # key management (same toolchain as cc-ci server)
|
||||
];
|
||||
|
||||
# loops user — claude sessions run as non-root (--dangerously-skip-permissions blocked for root).
|
||||
users.users.loops = {
|
||||
isNormalUser = true;
|
||||
home = "/home/loops";
|
||||
shell = pkgs.bash;
|
||||
extraGroups = [ "wheel" ];
|
||||
};
|
||||
security.sudo.wheelNeedsPassword = false;
|
||||
security.sudo.extraRules = [{
|
||||
users = [ "loops" ];
|
||||
commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }];
|
||||
}];
|
||||
|
||||
# Ensure /home/loops/.local/bin (claude + opencode) is on the loops user PATH.
|
||||
# opencode binary is installed there manually (not yet in nixpkgs); re-install if missing:
|
||||
# curl -sL https://github.com/anomalyco/opencode/releases/download/v1.15.13/opencode-linux-x64.tar.gz \
|
||||
# | tar -xz -C /home/loops/.local/bin opencode && chmod +x /home/loops/.local/bin/opencode
|
||||
environment.variables.PATH = lib.mkForce
|
||||
"/home/loops/.local/bin:/run/current-system/sw/bin:/run/wrappers/bin:/usr/bin:/bin";
|
||||
|
||||
# SSH config for the loops user — points to the cc-ci Hetzner server via tailnet.
|
||||
# HostName is the Hetzner cc-ci server's tailnet IP (cutover settled 2026-05-31).
|
||||
system.activationScripts.loopsSshConfig = ''
|
||||
mkdir -p /home/loops/.ssh && chown loops:users /home/loops/.ssh && chmod 700 /home/loops/.ssh
|
||||
# Only write if not already present (preserves manual customisation).
|
||||
if [ ! -f /home/loops/.ssh/config ]; then
|
||||
cat > /home/loops/.ssh/config <<'SSHCFG'
|
||||
Host cc-ci
|
||||
HostName 100.95.31.88
|
||||
User root
|
||||
IdentityFile /home/loops/.ssh/cc-ci-root-ed25519
|
||||
IdentitiesOnly yes
|
||||
StrictHostKeyChecking accept-new
|
||||
ServerAliveInterval 30
|
||||
SSHCFG
|
||||
chmod 600 /home/loops/.ssh/config
|
||||
chown loops:users /home/loops/.ssh/config
|
||||
fi
|
||||
'';
|
||||
|
||||
# claude-install — fetch the standalone Claude Code CLI for the loops user if missing.
|
||||
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
|
||||
'';
|
||||
};
|
||||
|
||||
# opencode web server — one shared instance; agent sessions attach to it for web visibility.
|
||||
# Serves the web UI at http://oc.commoninternet.net (via nginx below, tailscale-only).
|
||||
# Public provider configuration is tracked in the cc-ci repo; credentials
|
||||
# remain in /srv/cc-ci/.testenv.
|
||||
systemd.services.opencode-web = {
|
||||
description = "opencode web server for cc-ci agents";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" "tailscaled.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:/home/loops/.nix-profile/bin:/nix/profile/bin:/home/loops/.local/state/nix/profile/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 — reverse-proxy oc.commoninternet.net → opencode web server.
|
||||
# Bound to the tailscale IP so it is only reachable on the tailnet.
|
||||
# DNS: add A record oc.commoninternet.net → 100.84.190.30 (operator step if hostname access is wanted).
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
recommendedProxySettings = true;
|
||||
virtualHosts."oc.commoninternet.net" = {
|
||||
# Listen on the tailscale interface only — not the public IP. Both 80 and 443.
|
||||
# 443 uses a SELF-SIGNED cert (below): this name resolves to a CGNAT tailscale
|
||||
# IP, so Let's Encrypt HTTP-01 can't validate it and there is no DNS-01 provider
|
||||
# configured on this host. The trusted *.ci.commoninternet.net wildcard lives on
|
||||
# the coop-cloud swarm (91.98.47.73), not here, and doesn't cover this label.
|
||||
# Self-signed is fine because the vhost is tailnet-only (trusted network); the
|
||||
# browser shows a one-time trust prompt. (Chosen by operator, 2026-08-03.)
|
||||
#
|
||||
# The cert/key are created out of band — same convention as oc-htpasswd, NOT in
|
||||
# git and NOT in the nix store (a store path would be world-readable):
|
||||
#
|
||||
# /etc/nginx/oc-selfsigned.crt root:nginx 0644
|
||||
# /etc/nginx/oc-selfsigned.key root:nginx 0640
|
||||
#
|
||||
# Regenerate (10y, SANs = hostname + tailscale IP + ts.net name) with:
|
||||
# openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
||||
# -keyout /etc/nginx/oc-selfsigned.key -out /etc/nginx/oc-selfsigned.crt \
|
||||
# -subj /CN=oc.commoninternet.net \
|
||||
# -addext "subjectAltName=DNS:oc.commoninternet.net,DNS:cc-ci-orchestrator-1.taila4a0bf.ts.net,IP:100.84.190.30"
|
||||
# sudo chown root:nginx /etc/nginx/oc-selfsigned.{crt,key}
|
||||
# sudo chmod 644 /etc/nginx/oc-selfsigned.crt && sudo chmod 640 /etc/nginx/oc-selfsigned.key
|
||||
#
|
||||
# NOTE: like oc-htpasswd, if these files go missing nginx fails to START — which
|
||||
# would take the atproto vhost down with it. Recreate them before rebuilding on
|
||||
# a fresh host.
|
||||
listen = [
|
||||
{ addr = "100.84.190.30"; port = 80; ssl = false; }
|
||||
{ addr = "100.84.190.30"; port = 443; ssl = true; }
|
||||
];
|
||||
# addSSL (serve BOTH http+https) is required, not cosmetic: the NixOS nginx
|
||||
# module only renders the `ssl_certificate` directives when a vhost is flagged
|
||||
# as an SSL vhost. An explicit `listen ... ssl` + sslCertificate WITHOUT this
|
||||
# flag produces an SSL listener with no cert → `nginx -t` fails and the whole
|
||||
# service (atproto included) won't start.
|
||||
addSSL = true;
|
||||
sslCertificate = "/etc/nginx/oc-selfsigned.crt";
|
||||
sslCertificateKey = "/etc/nginx/oc-selfsigned.key";
|
||||
|
||||
# HTTP basic auth in front of opencode. The opencode web UI has no
|
||||
# authentication of its own and can drive agent sessions, so since this host
|
||||
# started serving 80/443 publicly (atproto-likes, 2026-08-01) it is worth a
|
||||
# second layer even though this vhost is tailnet-only.
|
||||
#
|
||||
# basicAuthFile, NOT basicAuth: the `basicAuth` attrset writes the password
|
||||
# into the nix store, which is world-readable. This file is created out of
|
||||
# band — same convention as /etc/ts-auth-key — and is not in git:
|
||||
#
|
||||
# /etc/nginx/oc-htpasswd root:nginx 0640, `oc:<bcrypt hash>`
|
||||
# /secrets/files/oc-basic-auth.txt the plaintext, loops-only 0600
|
||||
#
|
||||
# Rotate with:
|
||||
# P=$(python3 -c "import secrets,string;a=string.ascii_letters+string.digits;print(''.join(secrets.choice(a) for _ in range(32)))")
|
||||
# printf 'oc:%s\n' "$(mkpasswd -m bcrypt "$P")" | sudo tee /etc/nginx/oc-htpasswd
|
||||
# sudo chown root:nginx /etc/nginx/oc-htpasswd && sudo chmod 640 /etc/nginx/oc-htpasswd
|
||||
# sudo systemctl reload nginx
|
||||
#
|
||||
# NOTE: if this file goes missing, nginx fails to START — which would take
|
||||
# the atproto vhost down with it. Recreate it before rebuilding on a fresh
|
||||
# host.
|
||||
basicAuthFile = "/etc/nginx/oc-htpasswd";
|
||||
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:4096";
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
# p-lichen-orchestrator supervisor — the SEPARATE lichen.page testing/hardening orchestrator
|
||||
# (distinct from cc-ci-loops above). Reboot-resilience: on boot, resume the orchestrator's Remote
|
||||
# Control session + watchdog + pipeline via `engine/agents.py up`. Added 2026-07-08 after a reboot
|
||||
# (Hetzner rollback) left this orchestrator down while cc-ci-loops auto-recovered.
|
||||
# NOTE: still points at the /home path — will be re-pointed to /srv when that migration happens.
|
||||
systemd.services.lichen-orchestrator = {
|
||||
description = "p-lichen-orchestrator (lichen.page testing) — orchestrator + watchdog + pipeline";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" "tailscaled.service" "claude-install.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
serviceConfig = {
|
||||
# KillMode=process: this unit only LAUNCHES the tmux server, it does not own it. With the
|
||||
# default (control-group) systemd kills every leftover process in the cgroup when the unit
|
||||
# stops — and since one tmux server hosts every agent session on this host, a rebuild that
|
||||
# merely touched this unit wiped all of them (operator 2026-08-01). Only the (already
|
||||
# exited) main process is killed now; `systemctl stop` therefore does NOT tear down agents.
|
||||
KillMode = "process";
|
||||
Type = "oneshot"; RemainAfterExit = true;
|
||||
User = "loops"; Group = "users";
|
||||
WorkingDirectory = "/srv/lichen-orchestrator";
|
||||
};
|
||||
environment = { HOME = "/home/loops"; CLAUDE_BIN = "/home/loops/.local/bin/claude"; };
|
||||
path = [ pkgs.bash pkgs.tmux pkgs.git pkgs.python3 pkgs.openssh pkgs.nettools ];
|
||||
script = ''
|
||||
export PATH="/home/loops/.local/bin:$PATH"
|
||||
proj="/srv/lichen-orchestrator"
|
||||
# boot marker (best-effort; boot_id-gated logging can be added later)
|
||||
echo "$(cat /proc/sys/kernel/random/boot_id) boot $(date -u +%FT%TZ) — lichen-orchestrator up" \
|
||||
>> "$proj/.ao-state/boot.log" 2>/dev/null || true
|
||||
cd "$proj" && python3 engine/agents.py up || echo "p-lichen agents.py up failed"
|
||||
'';
|
||||
};
|
||||
|
||||
# project-orchestrator (fleet manager) — always-on so the operator can reach it over Remote
|
||||
# Control at any time (operator 2026-08-01). Same shape as lichen-orchestrator above; the PO's
|
||||
# own agents.toml declares NO `wake`, so the watchdog only heals a dead session — it never sends
|
||||
# periodic prompts. Starting it is `agents.py up`; that also starts its watchdog.
|
||||
systemd.services.project-orchestrator = {
|
||||
description = "project-orchestrator (fleet manager) — PO agent + watchdog, remote-control always up";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" "tailscaled.service" "claude-install.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
serviceConfig = {
|
||||
# KillMode=process: this unit only LAUNCHES the tmux server, it does not own it. With the
|
||||
# default (control-group) systemd kills every leftover process in the cgroup when the unit
|
||||
# stops — and since one tmux server hosts every agent session on this host, a rebuild that
|
||||
# merely touched this unit wiped all of them (operator 2026-08-01). Only the (already
|
||||
# exited) main process is killed now; `systemctl stop` therefore does NOT tear down agents.
|
||||
KillMode = "process";
|
||||
Type = "oneshot"; RemainAfterExit = true;
|
||||
User = "loops"; Group = "users";
|
||||
WorkingDirectory = "/srv/project-orchestrator";
|
||||
};
|
||||
environment = { HOME = "/home/loops"; CLAUDE_BIN = "/home/loops/.local/bin/claude"; };
|
||||
path = [ pkgs.bash pkgs.tmux pkgs.git pkgs.python3 pkgs.openssh pkgs.nettools ];
|
||||
script = ''
|
||||
export PATH="/home/loops/.local/bin:$PATH"
|
||||
proj="/srv/project-orchestrator"
|
||||
echo "$(cat /proc/sys/kernel/random/boot_id) boot $(date -u +%FT%TZ) — project-orchestrator up" \
|
||||
>> "$proj/.ao-state/boot.log" 2>/dev/null || true
|
||||
cd "$proj" && python3 engine/agents.py up || echo "PO agents.py up failed"
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{ modulesPath, ... }:
|
||||
{
|
||||
imports = [ (modulesPath + "/profiles/qemu-guest.nix") ];
|
||||
boot.loader = {
|
||||
efi.efiSysMountPoint = "/boot/efi";
|
||||
grub = {
|
||||
efiSupport = true;
|
||||
efiInstallAsRemovable = true;
|
||||
device = "nodev";
|
||||
};
|
||||
};
|
||||
fileSystems."/boot/efi" = { device = "/dev/disk/by-uuid/39A5-C7B9"; fsType = "vfat"; };
|
||||
boot.initrd.availableKernelModules = [ "ata_piix" "uhci_hcd" "xen_blkfront" "vmw_pvscsi" ];
|
||||
boot.initrd.kernelModules = [ "nvme" ];
|
||||
fileSystems."/" = { device = "/dev/sda1"; fsType = "ext4"; };
|
||||
|
||||
# 150G Hetzner volume (scsi-0HC_Volume_106342723) — holds the Rust build trees, which repeatedly
|
||||
# filled the 75G root. A full disk here does NOT error: it silently truncates whatever is being
|
||||
# written (it destroyed a plan file mid-write before anyone noticed).
|
||||
#
|
||||
# by-uuid, not /dev/sdb: device names are not stable across reboots and attaching another volume
|
||||
# would silently mount the wrong disk here.
|
||||
#
|
||||
# nofail is REQUIRED, not decoration: without it, a detached/failed volume makes the mount unit a
|
||||
# boot dependency, systemd drops to emergency mode, and the box comes up WITHOUT SSH. That failure
|
||||
# has already happened once on this host from a bad nixos config, and it needed a recovery to the
|
||||
# previous generation. The build cache is not worth risking access to the machine.
|
||||
fileSystems."/mnt/data" = {
|
||||
device = "/dev/disk/by-uuid/16cd6650-1399-4cb9-a696-54b70d83203a";
|
||||
fsType = "ext4";
|
||||
options = [ "defaults" "nofail" "x-systemd.device-timeout=10s" ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
# ⚠️ HISTORICAL / DEAD CONFIG — DO NOT DEPLOY. (superseded 2026-05-31)
|
||||
# This is the OLD Incus VM (`cc-ci-orchestrator`, tailnet 100.116.55.106) config. The orchestrator
|
||||
# now runs on Hetzner — the LIVE config is nix/hosts/cc-ci-orchestrator-hetzner/configuration.nix.
|
||||
# Kept only for historical comparison; it runs loops as root and hard-codes the dead cc-ci IP.
|
||||
# Delete once the old Incus VM is torn down.
|
||||
#
|
||||
# cc-ci-orchestrator VM — NixOS config (channel-based: nixos-24.11; deployed to /etc/nixos/configuration.nix)
|
||||
#
|
||||
# Purpose: a reboot-resilient host for the cc-ci Builder/Adversary loops + watchdog + the orchestrator
|
||||
# session, moved off the unstable 905 MiB Pi. See plan-orchestrator-migration.md.
|
||||
#
|
||||
# STATUS: DRAFT (Phase B). The nix-ld + claude-install bits need on-VM validation (the standalone
|
||||
# Claude Code is a Bun ELF binary; NixOS needs nix-ld to run a foreign dynamic binary). The
|
||||
# cc-ci-loops supervisor service is defined but NOT enabled until the workspace is staged (Phase C/D).
|
||||
{ config, pkgs, lib, modulesPath, ... }:
|
||||
{
|
||||
imports = [
|
||||
"${modulesPath}/virtualisation/incus-virtual-machine.nix"
|
||||
];
|
||||
|
||||
# --- base (mirrors the incus-base-vm) ---
|
||||
virtualisation.incus.agent.enable = true; # for `incus exec`
|
||||
services.cloud-init = { enable = true; network.enable = true; };
|
||||
services.openssh = { enable = true; settings.PermitRootLogin = "yes"; };
|
||||
networking.useDHCP = true;
|
||||
networking.nameservers = [ "1.1.1.1" "8.8.8.8" ];
|
||||
networking.firewall = { enable = true; trustedInterfaces = [ "tailscale0" ]; allowedTCPPorts = [ 22 ]; };
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
system.stateVersion = "24.11";
|
||||
|
||||
# --- tailscale (auto-auth from /etc/ts-auth-key, hostname from /etc/ts-hostname; written by cloud-init) ---
|
||||
services.tailscale = {
|
||||
enable = true;
|
||||
authKeyFile = "/etc/ts-auth-key";
|
||||
extraUpFlags = let h = lib.strings.removeSuffix "\n" (builtins.readFile /etc/ts-hostname);
|
||||
in [ "--hostname=${h}" "--ssh" ]; # --ssh: allow tailscale-ssh as a fallback path
|
||||
};
|
||||
|
||||
# --- swap: the Pi OOM lesson. 2 GB RAM is tight for 3 concurrent claude sessions; 4 GB disk swap
|
||||
# as a real overflow tier (zram is in-RAM and doesn't add capacity). ---
|
||||
swapDevices = [ { device = "/swapfile"; size = 4096; } ];
|
||||
|
||||
# --- nix-ld: lets the standalone Claude Code (foreign dynamic ELF / Bun) run on NixOS ---
|
||||
programs.nix-ld.enable = true;
|
||||
programs.nix-ld.libraries = with pkgs; [
|
||||
stdenv.cc.cc.lib # libstdc++ / libgcc_s
|
||||
zlib
|
||||
openssl
|
||||
curl
|
||||
glibc
|
||||
];
|
||||
|
||||
# --- packages the loops + launch.sh + orchestrator need ---
|
||||
environment.systemPackages = with pkgs; [
|
||||
git tmux python3 jq curl cacert
|
||||
gnused gawk coreutils gnugrep findutils util-linux
|
||||
nettools openssh # nc, ssh
|
||||
docker-client # `docker` CLI is not needed (deploys run on cc-ci), but handy for probes
|
||||
];
|
||||
|
||||
# --- loops user: non-root account for running claude (--dangerously-skip-permissions blocked for root) ---
|
||||
users.users.loops = {
|
||||
isNormalUser = true;
|
||||
home = "/home/loops";
|
||||
shell = pkgs.bash;
|
||||
extraGroups = [ "wheel" ]; # sudo access
|
||||
};
|
||||
security.sudo.wheelNeedsPassword = false; # passwordless sudo for wheel
|
||||
# Allow loops user to use tmux/claude without a password prompt
|
||||
security.sudo.extraRules = [{
|
||||
users = [ "loops" ];
|
||||
commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }];
|
||||
}];
|
||||
|
||||
# --- root PATH: ensure ~/.local/bin (where the standalone claude binary lives) is on root's PATH ---
|
||||
environment.variables.PATH = lib.mkForce "/root/.local/bin:/run/current-system/sw/bin:/run/wrappers/bin:/usr/bin:/bin";
|
||||
|
||||
# --- root ssh config: reach cc-ci DIRECTLY over the VM's own tailscale (this VM is a tailnet peer,
|
||||
# so NO SOCKS proxy is needed — unlike the Pi). Key staged at /root/.ssh/cc-ci-root-ed25519. ---
|
||||
system.activationScripts.ccciSshConfig = ''
|
||||
mkdir -p /root/.ssh && chmod 700 /root/.ssh
|
||||
cat > /root/.ssh/config <<'SSHCFG'
|
||||
Host cc-ci cc-nix-test 100.90.116.4
|
||||
HostName 100.90.116.4
|
||||
User root
|
||||
IdentityFile /root/.ssh/cc-ci-root-ed25519
|
||||
IdentitiesOnly yes
|
||||
StrictHostKeyChecking accept-new
|
||||
ServerAliveInterval 30
|
||||
SSHCFG
|
||||
chmod 600 /root/.ssh/config
|
||||
'';
|
||||
|
||||
# --- claude-install: idempotent oneshot — fetch the standalone Claude Code CLI if missing.
|
||||
# Runs via nix-ld. (Auth is a one-time operator step: `claude auth login` — see migration plan.) ---
|
||||
systemd.services.claude-install = {
|
||||
description = "Install the standalone Claude Code CLI if missing (idempotent)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" "nix-ld.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; };
|
||||
path = [ pkgs.curl pkgs.bash pkgs.coreutils pkgs.gnutar pkgs.gzip pkgs.unzip ];
|
||||
script = ''
|
||||
if [ ! -x /root/.local/bin/claude ]; then
|
||||
echo "installing standalone Claude Code CLI..."
|
||||
curl -fsSL https://claude.ai/install.sh | bash || echo "claude install failed (retry next activation)"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# --- cc-ci-loops supervisor (DEFINED, NOT YET ENABLED). Enabled in Phase D after the workspace
|
||||
# (/srv/cc-ci: launch.sh, plan, prompts, the loop clones, secrets) is staged. This is the
|
||||
# reboot-resilience fix: it runs launch.sh start on every boot. Mirrors the Pi's cc-ci-loops.service. ---
|
||||
systemd.services.cc-ci-loops = {
|
||||
description = "cc-ci Builder/Adversary loops + watchdog (launch.sh start, RESUME_PHASE)";
|
||||
wantedBy = [ "multi-user.target" ]; # enabled: workspace staged (Phase C/D 2026-05-30)
|
||||
after = [ "network-online.target" "tailscaled.service" "claude-install.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot"; RemainAfterExit = true; User = "root";
|
||||
WorkingDirectory = "/srv/cc-ci";
|
||||
};
|
||||
environment = { RESUME_PHASE = "1"; HOME = "/root"; };
|
||||
path = [ pkgs.bash pkgs.tmux pkgs.git pkgs.python3 pkgs.openssh pkgs.nettools ];
|
||||
script = ''
|
||||
[ -x /srv/cc-ci/cc-ci-plan/launch.sh ] && /srv/cc-ci/cc-ci-plan/launch.sh start || \
|
||||
echo "workspace not staged yet (/srv/cc-ci/cc-ci-plan/launch.sh missing) — skipping"
|
||||
'';
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user