dotfiles/nixos/hosts/phantom-ship.nix
DannyDannyDanny eccd9ee7dd phantom-ship + vps-relay: Forgejo on git.dannydannydanny.me
Phase 1 of the de-platform-from-GitHub roadmap (vimwiki/diary/2026-05-03.md).

- phantom-ship: services.forgejo bound to 0.0.0.0:3000, sqlite, lfs on,
  registration disabled, sign-in required.
- phantom-ship: open port 3000 only on the ZT interface (matches the
  bbbot pattern on sunken-ship — never exposed on WAN/Wi-Fi).
- vps-relay: Caddy vhost git.dannydannydanny.me reverse-proxies over ZT
  to phantom-ship:3000.

Manual steps still needed before this is reachable:
1. GoDaddy A record git.dannydannydanny.me -> 89.167.39.251
2. clan machines update phantom-ship && clan machines update vps-relay
3. On phantom-ship: bootstrap admin user (DISABLE_REGISTRATION is on):
     forgejo admin user create --admin --username danny \
       --email <addr> --password <pw>
2026-05-04 19:55:04 +02:00

288 lines
11 KiB
Nix

# NixOS server: SSH, auto-rebuild, NAT for rusty-anchor, OpenClaw gateway.
{ config, lib, pkgs, ... }:
let
# Telegram user ID(s) - gitignored, not committed to public repo.
# Create openclaw-allow-from.nix with e.g.: [ 12345678 ]
allowFromPath = ./openclaw-allow-from.nix;
openclawAllowFrom = if builtins.pathExists allowFromPath then import allowFromPath else [ ];
haraGmailMcp = pkgs.callPackage ../pkgs/hara-gmail-mcp { };
haraMcpServersJson = builtins.toJSON {
mcpServers = {
gmail = {
command = "${haraGmailMcp}/bin/hara-gmail-mcp";
args = [ ];
env = { };
};
};
};
in
{
imports = [
./phantom-ship-hardware.nix
../pkgs/hara-gmail-mcp/module.nix
];
networking.hostName = "phantom-ship";
networking.useDHCP = lib.mkDefault true;
networking.wireless.enable = true; # credentials in /etc/wpa_supplicant.conf (outside repo)
# NAT: share WiFi internet to rusty-anchor over ethernet
networking.nat = {
enable = true;
externalInterface = "wlp1s0";
internalInterfaces = [ "enp0s31f6" ];
};
networking.interfaces.enp0s31f6.ipv4.addresses = [{
address = "10.0.0.1";
prefixLength = 24;
}];
services.dnsmasq = {
enable = true;
settings = {
interface = "enp0s31f6";
dhcp-range = "10.0.0.10,10.0.0.50,24h";
dhcp-option = [ "3,10.0.0.1" "6,10.0.0.1" ]; # gateway + DNS
};
};
networking.firewall.trustedInterfaces = [ "enp0s31f6" ];
# Forgejo's HTTP backend is only allowed on the ZeroTier interface so
# vps-relay's Caddy can reach it via the ZT mesh. Same pattern as
# bbbot on sunken-ship — port 3000 is never exposed on WAN/Wi-Fi.
networking.firewall.interfaces."zt+".allowedTCPPorts = [ 3000 ];
hardware.enableRedistributableFirmware = true; # iwlwifi (Intel 8260) + GPU + BT firmware
boot.kernelParams = [ "consoleblank=60" ]; # blank TTY after 60s to reduce burn-in
# Turn off panel backlight after boot so the screen actually dims.
systemd.services.server-backlight-off = {
description = "Turn off panel backlight after console idle (reduce burn-in)";
after = [ "multi-user.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot";
script = ''
${pkgs.coreutils}/bin/sleep 65
for d in /sys/class/backlight/*; do
[ -f "$d/brightness" ] && echo 0 > "$d/brightness" 2>/dev/null || true
done
'';
};
time.timeZone = "Europe/Copenhagen";
nixpkgs.config.permittedInsecurePackages = [ "openclaw-2026.3.12" "openclaw-2026.4.12" ];
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "claude-code" ];
nix.settings.experimental-features = [ "nix-command" "flakes" ];
programs.nix-ld.enable = true; # run dynamically linked binaries (e.g. Claude Code remote CLI)
system.stateVersion = "24.11";
users.users.danny = {
isNormalUser = true;
extraGroups = [ "wheel" ];
# Password is locked (key-only SSH). Use NixOS installer or recovery to reset if needed.
openssh.authorizedKeys.keys = [
# Mac admin (~/.ssh/id_ed25519_phantom_ship on Daniel-Macbook-Air).
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDNl6PrKcEhmYJVqSXNcFU6cba3neekLBGnQCkD7lWAc danny@phantom-ship"
# Self-loopback (clan ssh-ng:// back to this host).
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPyEX8De/b+sMAxUZIqqiPphcrWCoAsN5p8gRFubzqvB danny@phantom-ship"
];
};
# root needs the mac admin key so `clan machines update` can SSH to
# root@<host> for SOPS upload.
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDNl6PrKcEhmYJVqSXNcFU6cba3neekLBGnQCkD7lWAc danny@phantom-ship"
];
# Key-only auth; no password or keyboard-interactive.
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
};
};
# Passwordless sudo for wheel.
security.sudo.wheelNeedsPassword = false;
environment.systemPackages = with pkgs; [
git # clone/bootstrap and dotfiles-rebuild timer
nodejs # npm for openclaw plugin installs
python3 # node-gyp dependency for openclaw plugins
wakeonlan # wake rusty-anchor: wakeonlan 00:16:cb:87:20:ba
bun # runtime for claude-code channel plugins
claude-code # Claude Code CLI (channels replaces openclaw)
openai-whisper # voice message transcription
ffmpeg # audio decoding for whisper
];
# OpenClaw AI gateway — DISABLED. Replaced by Claude Code Channels below.
# Config kept for easy rollback during validation; will be fully removed in a
# follow-up commit once Channels is proven stable. Workspace state at
# /var/lib/openclaw/ is preserved and also committed to vimwiki/openclaw/.
# Secrets (not in repo): /etc/openclaw/telegram-bot-token, /etc/openclaw/env (ANTHROPIC_API_KEY)
services.openclaw-gateway = {
enable = false;
environmentFiles = [ "/etc/openclaw/env" ];
servicePath = [ pkgs.git pkgs.nodejs pkgs.openai-whisper ];
config = {
gateway.mode = "local";
channels.telegram = {
tokenFile = "/etc/openclaw/telegram-bot-token";
allowFrom = openclawAllowFrom;
};
};
};
# Claude Code Channels — Telegram bridge for @HarakatBot.
# Uses claude.ai subscription auth (long-lived OAuth token) to bypass
# the API rate limits OpenClaw was hitting.
# Secret (not in repo): /etc/claude-channels/env (CLAUDE_CODE_OAUTH_TOKEN)
# Plugin + pairing state lives at /home/danny/.claude/ (set up interactively).
systemd.services.claude-channels = {
description = "Claude Code Channels (Telegram bridge for @HarakatBot)";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.claude-code pkgs.bun pkgs.git pkgs.util-linux ];
environment = {
HOME = "/home/danny";
};
serviceConfig = {
Type = "simple";
User = "danny";
Group = "users";
WorkingDirectory = "/home/danny";
EnvironmentFile = "/etc/claude-channels/env";
# claude needs a PTY; wrap with script(1). /dev/null discards the typescript.
# Permission bypass lives in ~/.claude/settings.json (permissions.defaultMode)
# — using the CLI flag triggers an interactive warning dialog at startup.
ExecStart = ''${pkgs.util-linux}/bin/script -qfc "${pkgs.claude-code}/bin/claude --channels plugin:telegram@claude-plugins-official --mcp-config /etc/hara/mcp-servers.json" /dev/null'';
Restart = "always";
RestartSec = 5;
};
};
# OpenClaw gateway needs write access to its config dir and repo clones.
systemd.tmpfiles.rules = [
"d /etc/openclaw 0775 root openclaw - -"
"d /var/lib/openclaw/repos 0750 openclaw openclaw - -"
];
# Hara Gmail MCP server (path 1: IMAP+SMTP). Replaced by an OAuth2
# Gmail+Calendar server in path 2.
services.hara-gmail-mcp = {
enable = true;
package = haraGmailMcp;
accounts = [
{
email = "powerhouseplayer@gmail.com";
password_file = "/etc/openclaw/gmail-powerhouseplayer-app-password";
}
{
email = "wildstylewarrior@gmail.com";
password_file = "/etc/openclaw/gmail-wildstylewarrior-app-password";
}
{
email = "danielth95@gmail.com";
password_file = "/etc/openclaw/gmail-danielth95-app-password";
}
];
};
# MCP server registry consumed by claude-channels via --mcp-config.
environment.etc."hara/mcp-servers.json" = {
text = haraMcpServersJson;
mode = "0644";
};
# Git config for the openclaw user: credential helper reads PAT from file.
# PAT (not in repo): /etc/openclaw/github-token (fine-grained, scoped to specific repos)
environment.etc."openclaw/gitconfig" = {
text = ''
[user]
name = OpenClaw Bot
email = noreply@openclaw.local
[credential "https://github.com"]
helper = "!f() { echo username=x-access-token; echo password=$(cat /etc/openclaw/github-token); }; f"
[safe]
directory = /var/lib/openclaw/repos
'';
mode = "0644";
};
# Harden the openclaw-gateway systemd service (only when enabled).
systemd.services.openclaw-gateway = lib.mkIf config.services.openclaw-gateway.enable {
environment.GIT_CONFIG_GLOBAL = "/etc/openclaw/gitconfig";
serviceConfig = {
ProtectHome = "read-only";
ProtectSystem = "strict";
PrivateTmp = true;
NoNewPrivileges = true;
ReadWritePaths = [ "/var/lib/openclaw" "/etc/openclaw" ];
};
};
# Shipyard — Telegram bot that lists Danny's mini-apps and collects feedback.
# Code deployed out-of-band via rsync to /home/danny/shipyard/
# (staying in-tree in ~/python-projects/26_shipyard/ until spun out to its own repo).
# Bot token (not in repo): ~danny/.secrets/telegram-bot-token-shipyard
# Data (feedback.jsonl, pointer cache): ~danny/.local/share/shipyard/
systemd.services.shipyard = let
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
python-telegram-bot
httpx
]);
in {
description = "Shipyard Telegram bot (mini-app launcher + feedback)";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = [ pythonEnv ];
environment = {
SHIPYARD_BOT_TOKEN_FILE = "/home/danny/.secrets/telegram-bot-token-shipyard";
};
serviceConfig = {
WorkingDirectory = "/home/danny/shipyard";
ExecStart = "${pythonEnv}/bin/python bot.py";
Restart = "on-failure";
RestartSec = 10;
User = "danny";
};
};
# Forgejo — self-hosted Git forge. Phase 1 of the de-platform-from-GitHub
# roadmap (vimwiki/diary/2026-05-03.md). Public URL git.dannydannydanny.me
# is fronted by Caddy on vps-relay reverse-proxying over ZT to :3000 here.
# Auth for now: HTTPS + PAT (osxkeychain credential helper on the Mac).
# SSH disabled in Phase 1; revisit if push-via-https gets annoying.
# Backups: TODO — snapshot /var/lib/forgejo/ once it's up.
services.forgejo = {
enable = true;
database.type = "sqlite3"; # personal scale; one user, plenty
lfs.enable = true;
settings = {
DEFAULT.APP_NAME = "git.dannydannydanny.me";
server = {
DOMAIN = "git.dannydannydanny.me";
ROOT_URL = "https://git.dannydannydanny.me/";
# Bind to all interfaces — firewall above scopes inbound to ZT.
HTTP_ADDR = "0.0.0.0";
HTTP_PORT = 3000;
DISABLE_SSH = true;
};
service = {
DISABLE_REGISTRATION = true; # admin-bootstrapped only
REQUIRE_SIGNIN_VIEW = true; # no anonymous browsing
};
session.COOKIE_SECURE = true;
log.LEVEL = "Info";
repository.DEFAULT_BRANCH = "main";
};
};
# Auto-rebuild service/timer + safe.directory provided by the
# shared dotfiles-rebuild NixOS module (see nixos/modules/dotfiles-rebuild.nix).
}