diff --git a/configuration.nix b/configuration.nix index 0ea2240..1fd0b01 100644 --- a/configuration.nix +++ b/configuration.nix @@ -9,18 +9,18 @@ ... }: { - nix.settings.experimental-features = [ - "nix-command" - "flakes" - ]; - nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; - sops.defaultSopsFile = ./secrets/home.yaml; sops.age.keyFile = "/.persist/root/.config/sops/age/keys.txt"; sops.secrets."root/ssh/desktop" = { path = "/root/.ssh/desktop"; mode = "0600"; }; + nix.settings.experimental-features = [ + "nix-command" + "flakes" + ]; + + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; boot.initrd.systemd.enable = true; boot.loader.systemd-boot.enable = true; diff --git a/executables/claude-code.sh b/executables/claude-code.sh deleted file mode 100644 index 646145b..0000000 --- a/executables/claude-code.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SSH_TARGET="sandbox@vsock%3" - -if [[ "$PWD" == /home/user* ]] || [[ "$PWD" == /etc/nixos* ]]; then - project="${PWD/\/home\/user/\/home\/sandbox}" -else - project="$(ssh "$SSH_TARGET" 'mktemp -d -p /tmp claude-XXXXXX')" -fi - -exec ssh -t "$SSH_TARGET" claude-code "$project" "$@" diff --git a/executables/opencode.sh b/executables/opencode.sh index 8bae728..8735693 100644 --- a/executables/opencode.sh +++ b/executables/opencode.sh @@ -3,7 +3,7 @@ set -euo pipefail SSH_TARGET="sandbox@vsock%3" -if [[ "$PWD" == /home/user* ]] || [[ "$PWD" == /etc/nixos* ]]; then +if [[ "$PWD" == /home/user* ]]; then project="${PWD/\/home\/user/\/home\/sandbox}" else project="$(ssh "$SSH_TARGET" 'mktemp -d -p /tmp opencode-XXXXXX')" diff --git a/firejail.nix b/firejail.nix index c618625..7a95668 100644 --- a/firejail.nix +++ b/firejail.nix @@ -4,10 +4,59 @@ pkgs, ... }: +let + agentProfile = pkgs.writeText "agent.profile" '' + whitelist /nix/store + whitelist /nix/var + read-only /nix/store + whitelist ''${HOME}/.cache/nix + whitelist ''${HOME}/.local/state/nix + whitelist /etc/nix + whitelist /etc/nixos + whitelist /nix/var/nix/daemon-socket + whitelist ''${HOME}/dev + whitelist ''${HOME}/work + whitelist ''${HOME}/.config + + blacklist ''${HOME} + blacklist /etc/nixos/secrets + blacklist /etc/shadow + blacklist /etc/sudoers + blacklist /etc/sudoers.d + blacklist /root + + caps.drop all + nonewprivs + noroot + seccomp + + no3d + nodvd + notv + nou2f + novideo + + + ignore net none + + machine-id + disable-mnt + private-dev + private-tmp + private-cache + ''; +in { programs.firejail = { enable = true; wrappedBinaries = { + claude = { + executable = "${pkgs.claude-code}/bin/claude"; + profile = agentProfile; + extraArgs = [ + "--private=~/.local/state/claude-home" + ]; + }; chromium-sandbox = { executable = "${pkgs.chromium}/bin/chromium"; profile = "${pkgs.firejail}/etc/firejail/chromium-browser.profile"; diff --git a/home/user.nix b/home/user.nix index cf62c79..afe9204 100644 --- a/home/user.nix +++ b/home/user.nix @@ -58,38 +58,25 @@ # "Xft.antialias" = 1; # "Xft.rgba" = "rgb"; # }; - systemd.user.services.screenshot-latest = { - Unit = { - Description = "Update latest.png symlink"; - }; - Service = { - Type = "oneshot"; - ExecStart = - let - screenshotDir = "${config.home.homeDirectory}/pictures/screenshots"; - script = pkgs.writeShellScript "screenshot-latest-update" '' - latest=$(${pkgs.coreutils}/bin/ls -t "${screenshotDir}"/*.png 2>/dev/null | head -1) - if [ -n "$latest" ]; then - ${pkgs.coreutils}/bin/ln -sf "$latest" "${screenshotDir}/latest.png" - fi - ''; - in - "${script}"; - }; - }; - systemd.user.paths.screenshot-latest = { + systemd.user.services.waypipe-socket = { Unit = { - Description = "Watch screenshot directory for new files"; + Description = "start waypipe client"; + After = [ "niri.service" ]; + Requires = [ "niri.service" ]; }; Install = { - WantedBy = [ "default.target" ]; + WantedBy = [ "graphical-session.target" ]; }; - Path = { - PathChanged = "${config.home.homeDirectory}/pictures/screenshots"; + Service = { + ExecStart = "${pkgs.waypipe}/bin/waypipe --socket /tmp/waypipe.sock client"; + ExecStartPost = "${pkgs.acl}/bin/setfacl -m g:sandbox:rw /tmp/waypipe.sock"; + ExecStopPost = "${pkgs.coreutils}/bin/rm /tmp/waypipe.sock"; + RuntimeDirectory = "waypipe"; + Type = "simple"; + Restart = "on-failure"; }; }; - sops.secrets."user/gpg/legacy_fnzr" = { }; home.activation.importGpgKey = config.lib.dag.entryAfter [ "writeBoundary" ] '' if [[ -f "${config.sops.secrets."user/gpg/legacy_fnzr".path}" ]]; then @@ -271,7 +258,7 @@ }; programs = { - # opencode.enable = true; + opencode.enable = true; dank-material-shell.enable = true; ssh = { enable = true; @@ -377,13 +364,11 @@ home.packages = with pkgs; [ xrdb + # (writeShellScriptBin "agent" '' + # machinectl shell agent@ ${waypipe}/bin/waypipe --socket /run/waypipe.sock server fish + # '') (writeShellApplication { - name = "claude-code"; - runtimeInputs = [ openssh ]; - text = builtins.readFile ../executables/claude-code.sh; - }) - (writeShellApplication { - name = "opencode"; + name = "opencode-sandbox"; runtimeInputs = [ openssh ]; text = builtins.readFile ../executables/opencode.sh; }) diff --git a/kernel/default.nix b/kernel/default.nix index 61a0777..6edd722 100644 --- a/kernel/default.nix +++ b/kernel/default.nix @@ -56,7 +56,6 @@ in keepEnv = true; persist = true; setEnv = [ - "HOME=/root" "EDITOR" "TERM" ]; diff --git a/microvm/sandbox.nix b/microvm/sandbox.nix index fa830af..cd530ed 100644 --- a/microvm/sandbox.nix +++ b/microvm/sandbox.nix @@ -26,13 +26,6 @@ in homeManagerModule ]; - nix.settings.experimental-features = [ - "nix-command" - "flakes" - ]; - nix.nixPath = [ "nixpkgs=${pkgs.path}" ]; - nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; - sops.defaultSopsFile = ./secrets/sandbox.yaml; sops.age.keyFile = "/.persist/secrets/age-keys.txt"; sops.secrets."root-password-hash" = { @@ -99,7 +92,6 @@ in fd ripgrep podman-compose - claude-code ]; environment.persistence."/.persist" = { @@ -194,17 +186,17 @@ in { mountPoint = "/var"; image = "var.img"; - size = 2 * 1024; + size = 256; } { mountPoint = "/nix/.rw-store"; image = "nix-store-overlay.img"; - size = 2 * 1024; + size = 512; } { mountPoint = "/.persist"; image = "persist.img"; - size = 2 * 1024; + size = 1; } ]; shares = [ @@ -220,12 +212,6 @@ in source = "/home/user/work"; mountPoint = "/home/sandbox/work"; } - { - proto = "virtiofs"; - tag = "nixos"; - source = "/etc/nixos"; - mountPoint = "/etc/nixos"; - } { proto = "virtiofs"; tag = "screenshots"; diff --git a/networking.nix b/networking.nix index 9e24f21..72cb6ee 100644 --- a/networking.nix +++ b/networking.nix @@ -5,10 +5,7 @@ ... }: { - imports = [ - ./wireguard.nix - ./tailscale.nix - ]; + imports = [ ./wireguard.nix ]; sops.secrets = { "wg0/conf".sopsFile = ./secrets/vpn.yaml; "wg-br0/conf".sopsFile = ./secrets/vpn.yaml; @@ -16,13 +13,6 @@ "wg-uk0/conf".sopsFile = ./secrets/vpn.yaml; }; - services.tailscale-netns = { - enable = true; - instances.work = { - extraDaemonFlags = [ "--accept-routes=false" ]; - }; - }; - networking = { hostName = hostname; networkmanager.enable = false; diff --git a/tailscale.nix b/tailscale.nix index 02a4f42..af1693e 100644 --- a/tailscale.nix +++ b/tailscale.nix @@ -10,24 +10,137 @@ with lib; let cfg = config.services.tailscale-netns; - instanceOptions = { - options.extraDaemonFlags = mkOption { - type = types.listOf types.str; - default = [ ]; - description = "Extra flags passed to `tailscaled`."; + instanceOptions = + { name, ... }: + { + options = { + authKeyFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to file containing a Tailscale auth key."; + }; + + hostname = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Tailscale hostname for this instance. + Defaults to "-". + ''; + }; + + advertiseRoutes = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Subnet routes to advertise (e.g. [ \"10.0.0.0/24\" ])."; + }; + + dns = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Bootstrap DNS server for the namespace before Tailscale is connected. + Defaults to `1.1.1.1`. Only used for the initial coordination server + connection; once Tailscale is up it manages DNS inside the namespace. + ''; + }; + + advertiseExitNode = mkOption { + type = types.bool; + default = false; + description = "Whether to advertise this node as an exit node."; + }; + + acceptRoutes = mkOption { + type = types.bool; + default = false; + description = "Whether to accept routes advertised by other nodes."; + }; + + exitNode = mkOption { + type = types.nullOr types.str; + default = null; + description = "Tailscale IP or hostname of exit node to use."; + }; + + extraUpFlags = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Extra flags passed to `tailscale up`."; + }; + + extraDaemonFlags = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Extra flags passed to `tailscaled`."; + }; + }; }; - }; + + mkUpFlags = + instanceCfg: + let + hostFlag = optional ( + instanceCfg.hostname != null + ) "--hostname=${escapeShellArg instanceCfg.hostname}"; + routeFlags = map (r: "--advertise-routes=${escapeShellArg r}") instanceCfg.advertiseRoutes; + exitFlag = optional instanceCfg.advertiseExitNode "--advertise-exit-node"; + acceptFlag = optional instanceCfg.acceptRoutes "--accept-routes"; + exitNodeFlag = optional ( + instanceCfg.exitNode != null + ) "--exit-node=${escapeShellArg instanceCfg.exitNode}"; + in + hostFlag ++ routeFlags ++ exitFlag ++ acceptFlag ++ exitNodeFlag ++ instanceCfg.extraUpFlags; + + mkAutoconnectScript = + name: instanceCfg: + pkgs.writers.writeBash "tailscale-autoconnect-${name}" '' + set -euo pipefail + + TS="${pkgs.tailscale}/bin/tailscale" + SOCKET="/run/tailscale-${name}/tailscaled.sock" + TS_ARGS="--socket $SOCKET" + + get_state() { + $TS $TS_ARGS status --json --peers=false 2>/dev/null | ${pkgs.jq}/bin/jq -r '.BackendState // "NoState"' + } + + last_state="" + while state="$(get_state)"; do + if [[ "$state" != "$last_state" ]]; then + case "$state" in + NeedsLogin|NeedsMachineAuth|Stopped) + echo "[tailscale-netns:${name}] server needs authentication, sending auth key" + $TS $TS_ARGS up \ + --auth-key "$(cat ${instanceCfg.authKeyFile})" \ + ${escapeShellArgs (mkUpFlags instanceCfg)} + ;; + Running) + echo "[tailscale-netns:${name}] tailscale is running" + ${pkgs.systemd}/bin/systemd-notify --ready + exit 0 + ;; + NoState) + echo "[tailscale-netns:${name}] tailscaled not ready yet" + ;; + *) + echo "[tailscale-netns:${name}] waiting for Running (state=$state)" + ;; + esac + fi + last_state="$state" + sleep 1 + done + ''; mkNetnsSetup = index: name: instanceCfg: let ns = "ts-${name}ns"; - hostAddr = "10.201.${toString index}.1"; - hostIP = "${hostAddr}/24"; + hostIP = "10.201.${toString index}.1/24"; nsIP = "10.201.${toString index}.2/24"; vethHost = "veth-ts-${name}"; vethNS = "veth-ts-${name}-ns"; - fallbackLines = concatMapStrings (n: " echo nameserver ${n}\n") config.networking.nameservers; in { "netns@ts-${name}" = { @@ -40,42 +153,12 @@ let set -euo pipefail ${pkgs.coreutils}/bin/mkdir -p /etc/netns/${ns} + # Build resolv.conf for the netns. Skip loopback addresses + # (systemd-resolved stub at 127.0.0.53 is not reachable from the netns). + NS_DNS="${if instanceCfg.dns != null then instanceCfg.dns else "1.1.1.1"}" { - dns_found=0 - if ${pkgs.systemd}/bin/resolvectl dns 2>/dev/null; then - saved_ifs="$IFS" - IFS=$'\n' - for line in $(${pkgs.systemd}/bin/resolvectl dns 2>/dev/null); do - case "$line" in - *:*) - server=$(echo "$line" | ${pkgs.gnused}/bin/sed 's/^[^:]*: //') - case "$server" in - 127.*|::1|"") ;; - *) echo "nameserver $server"; dns_found=1 ;; - esac - ;; - esac - done - IFS="$saved_ifs" - fi - - if [ "$dns_found" -eq 0 ]; then - while IFS= read -r rline; do - case "$rline" in - nameserver*) - ip=$(echo "$rline" | ${pkgs.gnused}/bin/sed 's/^nameserver //') - case "$ip" in - 127.*|::1) ;; - *) echo "$rline"; dns_found=1 ;; - esac - ;; - esac - done < /etc/resolv.conf - fi - ${optionalString (fallbackLines != "") '' - if [ "$dns_found" -eq 0 ]; then - ${fallbackLines} fi - ''} + ${pkgs.gawk}/bin/awk '$1 == "nameserver" && $2 !~ /^127\./ && $2 != "::1" {print}' /etc/resolv.conf + echo "nameserver $NS_DNS" } > /etc/netns/${ns}/resolv.conf ${pkgs.iproute2}/bin/ip netns add ${ns} @@ -88,7 +171,6 @@ let ${pkgs.iproute2}/bin/ip -n ${ns} addr add ${nsIP} dev ${vethNS} ${pkgs.iproute2}/bin/ip -n ${ns} link set ${vethNS} up ${pkgs.iproute2}/bin/ip -n ${ns} link set lo up - ${pkgs.iproute2}/bin/ip -n ${ns} route add default via ${hostAddr} ${pkgs.iptables}/bin/iptables -t nat -C POSTROUTING -s ${nsIP} -j MASQUERADE 2>/dev/null \ || ${pkgs.iptables}/bin/iptables -t nat -A POSTROUTING -s ${nsIP} -j MASQUERADE @@ -149,7 +231,10 @@ in description = "Tailscale instances configured in separate network namespaces."; example = literalExpression '' { - work = {}; + work = { + authKeyFile = "/run/secrets/ts-work/authkey"; + acceptRoutes = true; + }; } ''; }; @@ -162,12 +247,25 @@ in index: elem: let name = elem.name; - units = mkNetnsSetup index name elem.value; + instanceCfg = elem.value; + units = mkNetnsSetup index name instanceCfg; in [ - { name = "netns@ts-${name}"; value = units."netns@ts-${name}"; } - { name = "tailscaled-${name}"; value = units."tailscaled-${name}"; } + units."netns@ts-${name}" + units."tailscaled-${name}" ] + ++ optional (instanceCfg.authKeyFile != null) { + "tailscaled-autoconnect-${name}" = { + description = "Tailscale autoconnect for instance ${name}"; + after = [ "tailscaled-${name}.service" ]; + requires = [ "tailscaled-${name}.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "notify"; + }; + script = "${mkAutoconnectScript name instanceCfg}"; + }; + } ) (attrsToList cfg.instances) ) );