diff --git a/components/website-editor/README.md b/components/website-editor/README.md index 8eb6f46..a990199 100644 --- a/components/website-editor/README.md +++ b/components/website-editor/README.md @@ -16,6 +16,62 @@ Guest services start inside the VM, not on the host. - Guest: `192.168.83.2/24`, gateway `192.168.83.1` - Host bridge: `agentbr0`, containing only the `agent-grace` tap. +## Guest access and first boot + +From the homelab's `admin` account: + +```sh +ssh grace-editor +``` + +The host alias uses `~/.ssh/grace-editor`; its public key is declared in +`components.website-editor.authorizedKeys`. The private key stays on the host. +Guest SSH accepts key authentication from the bridge gateway only; port 22 is +not forwarded from the LAN. From another device, SSH into patroclus first. +The guest has passwordless sudo. Its own SSH host key persists on the home +volume, so rebuilding/rebooting it does not change the server identity. + +The private website repository requires a guest-specific GitHub deploy key. +Before the initial clone, create one **inside the guest**: + +```sh +install -d -m 700 ~/.ssh +ssh-keygen -t ed25519 -N '' -C grace-editor -f ~/.ssh/id_ed25519 +cat ~/.ssh/id_ed25519.pub +``` + +Add that public key to `ajaxbits/gracebobber` → Settings → Deploy keys, enabling +write access for publishing. Keep the private half inside the guest. The +bootstrap uses SSH transport and a pinned GitHub host key; it never receives +the administrator's broad GitHub token. Bootstrap initially fails until this +deploy key is provisioned; guest SSH remains available independently. +Deploy keys authenticate Git operations only. Watching private Actions runs +with `gh` additionally requires a repository-scoped token with Actions read +access; Copilot authentication is not a substitute for that permission. + +Then run **inside the guest**: + +```sh +sudo systemctl restart grace-editor-bootstrap +sudo systemctl start opencode2-grace-editor grace-editor-preview +sudo systemctl status grace-editor-bootstrap opencode2-grace-editor grace-editor-preview +``` + +For bootstrap errors, use `sudo journalctl -u grace-editor-bootstrap -b` in +the guest. Its home filesystem is explicitly mounted before boot activation, +then tmpfiles sets the volume root's ownership to `agent:users`. Dependency +installation is stamped only after `npm ci` succeeds and repeated after a +lockfile/Node version change or incomplete install. + +OpenCode's generated server password is available to the guest administrator +in `sudo journalctl -u opencode2-grace-editor -b`. Use it to connect the web UI, +then connect GitHub Copilot there. That OAuth connection and the repository +deploy key serve separate purposes. Both OpenCode state and the checkout are +on `/home/agent` and survive a VM restart. + +`just dev` checks the preview over guest loopback, while reporting the LAN URL +to Grace. The agent is deliberately unable to connect to that host URL itself. + ## Network ownership This component does **not** enable the NixOS global nftables/NAT services or diff --git a/components/website-editor/options.nix b/components/website-editor/options.nix index 50b25b4..74acbf1 100644 --- a/components/website-editor/options.nix +++ b/components/website-editor/options.nix @@ -5,6 +5,11 @@ in { options.components.website-editor = { enable = mkEnableOption "Grace Bobber's isolated website-editor MicroVM"; + authorizedKeys = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Public SSH keys for the guest agent account. SSH is reachable from the host only, not forwarded to the LAN."; + }; lan = { interface = mkOption { type = types.strMatching "[a-zA-Z0-9_.-]+"; diff --git a/components/website-editor/vm.nix b/components/website-editor/vm.nix index b5120e8..c2f6c05 100644 --- a/components/website-editor/vm.nix +++ b/components/website-editor/vm.nix @@ -3,16 +3,51 @@ let cfg = config.components.website-editor; hostConfig = config; hostName = "grace-editor"; - repo = "https://github.com/ajaxbits/gracebobber.git"; + repo = "git@github.com:ajaxbits/gracebobber.git"; + tools = with pkgs; [ + inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.opencode2 + bash + curl + exiftool + fd + git + gh + imagemagick + jujutsu + jq + just + nodejs_22 + openssh + poppler-utils + pkg-config + python3 + ripgrep + vips + ]; + globalOpenCodeConfig = pkgs.writeText "grace-editor-opencode.json" (builtins.toJSON { + "$schema" = "https://opencode.ai/config.json"; + update = "disable"; + warming = false; + }); in { config = lib.mkIf cfg.enable { + + programs.ssh.extraConfig = '' + Host grace-editor + HostName ${cfg.vm.ip} + User agent + IdentityFile ~/.ssh/grace-editor + IdentitiesOnly yes + ''; + age.secrets."grace-editor/opencode.env" = { file = ../../secrets/grace-editor/opencode.env.age; path = "/run/grace-editor-secrets/opencode.env"; mode = "0400"; }; + # Intentionally no autostart: run `systemctl start microvm@grace-editor` # when the editing environment is wanted. microvm.vms.${hostName} = { @@ -49,24 +84,50 @@ in home = "/home/agent"; createHome = true; extraGroups = [ "wheel" ]; + openssh.authorizedKeys.keys = cfg.authorizedKeys; }; security.sudo.wheelNeedsPassword = false; - environment.systemPackages = with pkgs; [ - inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.opencode2 - bash - curl - exiftool - fd - git - imagemagick - jj - just - nodejs_22 - poppler-utils - pkg-config - ripgrep - vips + environment.systemPackages = tools; + nix.settings.experimental-features = [ "nix-command" "flakes" ]; + # GitHub's published Ed25519 host key (https://api.github.com/meta). + # First-boot cloning must not block on an interactive trust prompt. + programs.ssh.knownHosts."github.com".publicKey = + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"; + + services.openssh = { + enable = true; + openFirewall = false; + settings = { + PasswordAuthentication = false; + KbdInteractiveAuthentication = false; + PermitRootLogin = "no"; + AllowUsers = [ "agent" ]; + }; + hostKeys = [ { + path = "/home/agent/.ssh-host-keys/ssh_host_ed25519_key"; + type = "ed25519"; + } ]; + }; + # Management is host-initiated; the host forwards only the web ports. + networking.firewall.extraCommands = '' + iptables -A nixos-fw -s ${cfg.vm.gateway}/32 -p tcp --dport 22 -j nixos-fw-accept + ''; + networking.firewall.extraStopCommands = '' + iptables -D nixos-fw -s ${cfg.vm.gateway}/32 -p tcp --dport 22 -j nixos-fw-accept 2>/dev/null || true + ''; + + # A newly formatted volume has a root-owned filesystem root. User + # creation alone doesn't chown an existing home/mount point. + fileSystems."/home/agent".neededForBoot = true; + systemd.tmpfiles.rules = [ + "d /home/agent 0700 agent users -" + "d /home/agent/.ssh-host-keys 0700 root root -" + "d /home/agent/.config 0700 agent users -" + "d /home/agent/.config/opencode 0700 agent users -" + # Update policy is global-only in V2. L (without +) preserves an + # existing guest configuration rather than overwriting user edits. + "L /home/agent/.config/opencode/opencode.json - - - - ${globalOpenCodeConfig}" ]; microvm = { @@ -112,22 +173,39 @@ in wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; - path = with pkgs; [ git jj nodejs_22 ]; + path = tools; + environment.HOME = "/home/agent"; + unitConfig.RequiresMountsFor = "/home/agent"; serviceConfig = { Type = "oneshot"; + RemainAfterExit = true; User = "agent"; WorkingDirectory = "/home/agent"; + TimeoutStartSec = "15min"; }; script = '' + export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=15" if [ ! -d gracebobber/.git ]; then git clone ${repo} gracebobber fi cd gracebobber + if [ -z "$(jj config get user.name 2>/dev/null || true)" ]; then + jj config set --user user.name "Grace website editor" + fi + if [ -z "$(jj config get user.email 2>/dev/null || true)" ]; then + jj config set --user user.email "grace-editor@localhost" + fi if [ ! -d .jj ]; then jj git init --colocate + jj bookmark track main --remote=origin fi - if [ ! -d node_modules ]; then + # A failed npm ci may leave node_modules behind. Only record the + # lockfile and Node version once an installation actually succeeds. + signature="$(sha256sum package-lock.json | cut -d ' ' -f1):$(node --version)" + if [ ! -x node_modules/.bin/astro ] || + [ "$(cat node_modules/.grace-editor-deps 2>/dev/null || true)" != "$signature" ]; then npm ci + printf '%s\n' "$signature" > node_modules/.grace-editor-deps fi ''; }; @@ -136,12 +214,14 @@ in wantedBy = [ "multi-user.target" ]; after = [ "grace-editor-bootstrap.service" ]; requires = [ "grace-editor-bootstrap.service" ]; + path = tools ++ [ "/run/wrappers" "/run/current-system/sw" ]; serviceConfig = { User = "agent"; WorkingDirectory = "/home/agent/gracebobber"; Environment = [ "HOME=/home/agent" - "GRACE_EDITOR_PREVIEW_URL=http://172.22.0.10:${toString cfg.previewPort}" + "GRACE_EDITOR_PREVIEW_URL=http://${cfg.lan.hostIP}:${toString cfg.previewPort}" + "GRACE_EDITOR_PREVIEW_CHECK_URL=http://127.0.0.1:${toString cfg.previewPort}" ]; EnvironmentFile = hostConfig.age.secrets."grace-editor/opencode.env".path; ExecStart = "${inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.opencode2}/bin/opencode2 serve --hostname 0.0.0.0 --port ${toString cfg.editorPort}"; @@ -154,6 +234,8 @@ in wantedBy = [ "multi-user.target" ]; after = [ "grace-editor-bootstrap.service" ]; requires = [ "grace-editor-bootstrap.service" ]; + path = tools; + environment.HOME = "/home/agent"; serviceConfig = { User = "agent"; WorkingDirectory = "/home/agent/gracebobber"; diff --git a/hosts/patroclus/configuration.nix b/hosts/patroclus/configuration.nix index 2482ed4..c4fe72d 100644 --- a/hosts/patroclus/configuration.nix +++ b/hosts/patroclus/configuration.nix @@ -54,7 +54,13 @@ in "github:ajaxbits/config#patroclus"; }; cloudflared.enable = true; - website-editor.enable = true; + website-editor = { + enable = true; + authorizedKeys = [ + # Private key stays on patroclus in ~admin/.ssh/grace-editor. + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICDgXkJ7RFsYogbUgTKwxRcGx70e5pULOzDFl8e5i9yp patroclus grace-editor administration" + ]; + }; ebooks.enable = false; mediacenter = { enable = true;