Files
cc-ci-orchestrator/nix/hosts/cc-ci/configuration.nix
T
notplantsandClaude Opus 5 e7aa055784 /secrets is the authoritative location for every secret, incl. the ssh host keys
Operator rule: secrets live in /secrets and consumers reach them from there.
Three subdirectories with the ownership each consumer needs — files/ (loops),
host/ (root: ssh host keys + sops age identity), nginx/ (root:nginx: the
opencode UI htpasswd) — under a 0711 /secrets so nginx can traverse to its own
without the directory being listable.

sshd's hostKeys and sops-nix's sshKeyPaths/keyFile are pointed at /secrets
DIRECTLY rather than through symlinks: the ed25519 host key is load-bearing
beyond ssh, since its age identity (age1tmvg…) is a recipient of cc-ci-secrets,
and a dangling symlink would let sshd write a NEW key and silently make every
cc-ci secret undecryptable. The /etc/ssh symlinks are added for discoverability
only, so nothing depends on activation ordering.

nginx's htpasswd path becomes an option (opencodeUiHtpasswdFile) defaulting
under /secrets, rather than a hard-coded /etc/nginx path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqkQq3CDmFWcQ7u1LzoyRz
2026-09-08 17:23:28 +00:00

145 lines
7.9 KiB
Nix
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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,
# 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
# self.nixosModules.orchestrator-host nix/modules/orchestrator-host.nix — loops user, CLIs
# See README.md for provisioning (Hetzner Debian → nixos-infect → this flake) and staging.
{ lib, pkgs, ... }:
{
imports = [
./hardware.nix
./networking.nix
];
networking.hostName = "cc-ci";
# ---- cc-ci server identity --------------------------------------------------------------
# Public address: acme-dns binds to it and publishes it as the `ns-acme` glue record; the
# Gandi A records for ci / *.ci / ns-acme .commoninternet.net point here.
cc-ci.publicIPv4 = "195.201.88.249";
# cc-ci is a plain flake input here (no private submodule), so the sops file is the one in
# the deployed --recursive checkout the weekly sweep runs from (README "Stage the workspace").
cc-ci.sopsFile = "/etc/cc-ci/secrets/secrets.yaml";
# ---- 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";
# Weekly self-update (Tue 03:00 UTC; skips itself while CI is busy; see nix/modules/auto-update.nix).
cc-ci-orchestrator.autoUpdate.enable = true;
# The opencode UI: traefik (public 443, the *.ci.commoninternet.net cert) → nginx basic auth.
cc-ci-orchestrator.opencodeUiHost = "oc.ci.commoninternet.net";
cc-ci-orchestrator.opencodeUiTraefikNetwork = "proxy";
# ---- 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 443 (traefik → nginx basic auth). fail2ban below guards the two logins.
# ---- /secrets is THE authoritative location for this host's secret material ---------------
# Operator rule (2026-09-08): every secret lives under /secrets; anything that needs one either
# reads it from there directly (where we own the path) or reaches it by a symlink (where the
# consuming software fixes the path). One directory to audit, back up, and reason about.
#
# /secrets/files loops:users 0700 the agent's secrets (testenv, opencode auth, its ssh keys)
# /secrets/host root:root 0700 host identity: ssh host keys + the sops age identity
# /secrets/nginx root:nginx 0750 the opencode UI htpasswd (nginx must read it)
#
# /secrets itself is 0711: traversable so nginx can reach its own subdirectory, not listable.
systemd.tmpfiles.rules = [
"d /secrets 0711 root root -"
"d /secrets/host 0700 root root -"
"d /secrets/nginx 0750 root nginx -"
# Convenience symlinks at the conventional paths, so an operator (or a tool that assumes the
# usual location) still finds the host keys. NOT load-bearing: sshd and sops-nix below are
# pointed at /secrets directly, precisely so nothing depends on symlink/activation ordering.
"L+ /etc/ssh/ssh_host_ed25519_key - - - - /secrets/host/ssh_host_ed25519_key"
"L+ /etc/ssh/ssh_host_ed25519_key.pub - - - - /secrets/host/ssh_host_ed25519_key.pub"
"L+ /etc/ssh/ssh_host_rsa_key - - - - /secrets/host/ssh_host_rsa_key"
"L+ /etc/ssh/ssh_host_rsa_key.pub - - - - /secrets/host/ssh_host_rsa_key.pub"
];
# sops-nix: the cc-ci server module hard-codes /etc/ssh/... and /var/lib/sops-nix/key.txt.
# Override both to the authoritative copies. THE ED25519 HOST KEY IS LOAD-BEARING BEYOND SSH:
# its age identity (age1tmvg…) is a recipient of cc-ci-secrets, so replacing or regenerating it
# makes every cc-ci secret undecryptable. Move it, never re-create it.
sops.age.sshKeyPaths = lib.mkForce [ "/secrets/host/ssh_host_ed25519_key" ];
sops.age.keyFile = lib.mkForce "/secrets/host/sops-age-key.txt";
# ---- ssh ----------------------------------------------------------------------------------
services.openssh = {
enable = true;
settings.PermitRootLogin = "yes";
# Host keys live in /secrets (above). sshd is pointed here directly rather than through the
# /etc/ssh symlinks, so it can never write a NEW key through a dangling link — that would
# silently rotate the age identity that decrypts cc-ci-secrets.
hostKeys = [
{ path = "/secrets/host/ssh_host_ed25519_key"; type = "ed25519"; }
{ path = "/secrets/host/ssh_host_rsa_key"; type = "rsa"; bits = 4096; }
];
};
# Root keys: PUBLIC keys, tracked deliberately in ./ssh-keys (one per line, blank lines ok).
users.users.root.openssh.authorizedKeys.keys =
builtins.filter (s: s != "") (lib.splitString "\n" (builtins.readFile ./ssh-keys));
# 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 opencode UI
# rides 443 through traefik (orchestrator-host.nix), so only ssh is opened here.
networking.firewall = {
enable = true;
allowedTCPPorts = [ 22 ];
};
# ---- 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 with the real client IP to the journal; the
# built-in nginx-http-auth filter matches them). Those clients arrive through traefik's
# docker-published 443, which iptables FORWARDs rather than INPUTs, so the ban for that jail
# goes into the DOCKER-USER chain — an INPUT rule would never see the traffic.
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";
# NixOS nginx logs errors to stderr → the journal, not /var/log/nginx/error.log (which
# exists but stays empty). Read the unit's journal instead.
backend = "systemd";
journalmatch = "_SYSTEMD_UNIT=nginx.service";
banaction = "iptables-allports";
chain = "DOCKER-USER";
};
};
networking.nameservers = [ "1.1.1.1" "8.8.8.8" ];
# This host's own public names resolve to itself regardless of external DNS state (host
# processes: the drone runner, the harness, the orchestrator; containers use the resolvers
# above). Per-run recipe domains are random and cannot be pinned — those follow public DNS.
networking.hosts."195.201.88.249" = [
"ci.commoninternet.net"
"drone.ci.commoninternet.net"
"report.ci.commoninternet.net"
"traefik.ci.commoninternet.net"
"warm-keycloak.ci.commoninternet.net"
"oc.ci.commoninternet.net"
];
# ---- memory: 8 GB RAM shared by the swarm (recipe deploys) and 36 agent sessions ---------
swapDevices = [ { device = "/swapfile"; size = 8192; } ];
# ssh client for root (the orchestrator's `ssh cc-ci` goes through the loops user's own config).
environment.systemPackages = [ pkgs.openssh ];
# Fresh NixOS 26.05 install (nixos-infect, 2026-09-07). Never change this on an existing host.
system.stateVersion = "26.05";
}