Skip to content

feat: add NixOS declarative infrastructure for deployment#34

Open
ViZiD wants to merge 1 commit into
masterfrom
nix-infra
Open

feat: add NixOS declarative infrastructure for deployment#34
ViZiD wants to merge 1 commit into
masterfrom
nix-infra

Conversation

@ViZiD

@ViZiD ViZiD commented Mar 21, 2026

Copy link
Copy Markdown
Collaborator

Add complete NixOS deployment setup with Podman containers, Traefik reverse proxy, sops-nix secrets management, and deploy-rs integration. Includes GitHub Actions CI/CD pipeline for automated builds and deploys.

Need change secrets for prod

Add complete NixOS deployment setup with Podman containers, Traefik
reverse proxy, sops-nix secrets management, and deploy-rs integration.
Includes GitHub Actions CI/CD pipeline for automated builds and deploys.
@greptile-apps

greptile-apps Bot commented Mar 21, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 1/5

  • Not safe to merge — the CI deploy user has full passwordless root access, which combined with a stolen deploy key gives an attacker complete server control.
  • The github-ci user privilege escalation (wheel + passwordless sudo) is a critical security defect that contradicts the documented intent and creates a significant blast radius for a GitHub secret compromise. The unverified SSH host fingerprint in CI and disabled TLS for the database connection compound the risk. These must be addressed before this infrastructure is used in production.
  • deploy/nix/users/github-ci.nix (privilege escalation), .github/workflows/deploy.yml (MITM risk), deploy/gen-host-keys.sh (key leak), deploy/nix/services/letta-bot.nix (TLS disabled)

Important Files Changed

Filename Overview
deploy/nix/users/github-ci.nix CI deploy user is placed in the wheel group, granting full passwordless sudo despite documentation stating it should only be able to restart services — a critical privilege escalation risk.
.github/workflows/deploy.yml CI/CD pipeline builds and deploys on v* tags; ssh-keyscan blindly trusts the deploy host key at runtime, making the pipeline vulnerable to MITM during deployment.
deploy/nix/services/letta-bot.nix Well-structured NixOS module for Podman-based bot and Gel containers; GEL_CLIENT_TLS_SECURITY = "insecure" disables TLS verification on the database connection.
deploy/gen-host-keys.sh Generates SSH host keys and derives age public keys, but leaves the private key in a temp directory with no cleanup, risking secret key exposure on developer machines.
deploy/nix/hosts/dev/services.nix Wires sops secrets into service env files correctly; image name has a case inconsistency (Wizard1209 vs lowercase default in module).
deploy/nix/hosts/common.nix Shared base config sets wheelNeedsPassword = false globally — combined with the github-ci wheel membership this creates the privilege escalation path.
deploy/nix/dev/devshell.nix deploy-rs is included twice (once from nixpkgs, once from flake input), causing redundant packages in the dev shell.

Sequence Diagram

sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub Actions
    participant GHCR as GHCR Registry
    participant Server as NixOS Server

    Dev->>GH: git push tag v*
    GH->>GHCR: docker build + push :latest
    GH->>Server: ssh-keyscan (unverified!)
    GH->>Server: deploy-rs via github-ci SSH user
    Note over GH,Server: github-ci has wheel (full sudo)

    Server->>Server: sops-nix decrypts secrets.yaml
    Server->>Server: systemd starts podman-network-letta
    Server->>Server: Podman starts gel container
    Server->>Server: Podman starts letta-bot container

    Note over Server: Traefik routes:
    Note over Server: domain/bot → letta-bot:8090
    Note over Server: db.domain → gel:5656
    Note over Server: tr.domain → Traefik dashboard
Loading

Last reviewed commit: "feat: add NixOS decl..."

{
users.users.github-ci = {
isNormalUser = true;
extraGroups = [ "wheel" ];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 github-ci user has unrestricted root access via wheel

The github-ci user is added to the wheel group, and common.nix sets security.sudo.wheelNeedsPassword = false. This means any compromise of the DEPLOY_SSH_KEY GitHub secret immediately grants an attacker full passwordless root access to the production server.

CLAUDE.md documents this user as "CI deploy user (sudo restart only)", but the implementation provides full unrestricted sudo. To match that intent, the user should be removed from wheel and given a targeted sudo rule limited to restarting the specific services:

{ ... }:
{
  users.users.github-ci = {
    isNormalUser = true;
    # No wheel group - restricted to specific sudo commands only
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA+ed2XXpDNh9Nr5ffKC9KTWKK3MNXVTz4bbbRRbA21j github-deploy"
    ];
  };

  security.sudo.extraRules = [
    {
      users = [ "github-ci" ];
      commands = [
        { command = "/run/current-system/sw/bin/systemctl restart podman-letta-bot"; options = [ "NOPASSWD" ]; }
      ];
    }
  ];
}

WEBHOOK_PATH = cfg.webhookPath;
GEL_HOST = "gel";
GEL_PORT = toString cfg.gel.port;
GEL_CLIENT_TLS_SECURITY = "insecure";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 TLS verification disabled for Gel connection

GEL_CLIENT_TLS_SECURITY = "insecure" completely disables TLS certificate verification between the bot container and the Gel container. Even though both containers run on the same host and the Gel server uses a self-signed certificate (GEL_SERVER_TLS_CERT_MODE = "generate_self_signed"), disabling verification means any process that can intercept the Podman network traffic can silently perform a MITM attack on database queries.

A safer alternative is to use "no_system_trust" (or "strict" with a known CA) instead, which validates the certificate but does not require it to be signed by a system CA:

Suggested change
GEL_CLIENT_TLS_SECURITY = "insecure";
GEL_CLIENT_TLS_SECURITY = "no_system_trust";

Comment thread deploy/gen-host-keys.sh
Comment on lines +11 to +24
TEMP_DIR=$(mktemp -d)

install -d -m755 "$TEMP_DIR/etc/ssh"

ssh-keygen -t ed25519 -f "$TEMP_DIR/etc/ssh/ssh_host_ed25519_key" -N "" -C "" -q

chmod 600 "$TEMP_DIR/etc/ssh/ssh_host_ed25519_key"
chmod 644 "$TEMP_DIR/etc/ssh/ssh_host_ed25519_key.pub"

PUB_KEY=$(cat "$TEMP_DIR/etc/ssh/ssh_host_ed25519_key.pub")
AGE_KEY=$(echo "$PUB_KEY" | ssh-to-age)

echo "$TEMP_DIR"
echo "- &$HOST $AGE_KEY"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Private key left in temp dir with no cleanup

The script generates an ed25519 host key pair in a mktemp -d directory and prints only the path and the derived age public key. The private key (ssh_host_ed25519_key) remains on disk in the temp directory indefinitely — it is never copied to the target host, and there are no cleanup instructions.

If this script is run on a developer machine, the private key stays in /tmp/tmp.XXXXXX/etc/ssh/ until the next reboot (or longer if /tmp is persistent). Anyone with local access could read it and derive the corresponding age private key to decrypt the sops secrets.

Consider adding either:

  1. A trap "rm -rf $TEMP_DIR" EXIT at the top to guarantee cleanup, plus explicit instructions in the output to copy the key to the target before cleanup.
  2. Or provide instructions in the output itself to move the private key to the target host and remove the temp dir immediately.

Example addition:

trap 'rm -rf "$TEMP_DIR"' EXIT

echo ""
echo "IMPORTANT: Copy the private key to the target before this script exits:"
echo "  scp $TEMP_DIR/etc/ssh/ssh_host_ed25519_key root@<host>:/etc/ssh/"


services.letta-bot = {
enable = true;
image = "ghcr.io/Wizard1209/letta-client-bot:latest";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Image name case inconsistency with module default

The image reference uses a capital W in Wizard1209:

image = "ghcr.io/Wizard1209/letta-client-bot:latest";

The default value in letta-bot.nix uses lowercase:

default = "ghcr.io/wizard1209/letta-client-bot:latest";

GHCR normalises repository names to lowercase at push time, so both references resolve to the same image today. However, this inconsistency is confusing and could cause issues with tools that treat image names case-sensitively. Consider normalising to lowercase consistently:

Suggested change
image = "ghcr.io/Wizard1209/letta-client-bot:latest";
image = "ghcr.io/wizard1209/letta-client-bot:latest";

Comment on lines +14 to +29
'';

packages = [
]
++ (with pkgs; [
git
nix
nixfmt
nixos-anywhere
sops
ssh-to-age
wget
deploy-rs
])
++ (with inputs'; [
deploy-rs.packages.default

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 deploy-rs included twice in devShell

Both pkgs.deploy-rs (from nixpkgs) and inputs'.deploy-rs.packages.default (from the flake input) are added to the shell's packages list. This adds the same binary twice, making the shell slightly heavier and potentially confusing (different builds of the same tool in PATH).

Since deploy-rs is pinned in flake.lock, prefer the flake input version exclusively and remove the nixpkgs one:

        packages = [
        ]
        ++ (with pkgs; [
          git
          nix
          nixfmt
          nixos-anywhere
          sops
          ssh-to-age
          wget
        ])
        ++ (with inputs'; [
          deploy-rs.packages.default
        ]);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant