Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

27 changed files with 254 additions and 2334 deletions

View file

@ -9,62 +9,7 @@
"mcp__web-reader__webReader",
"Bash(python3 -c \"import json; json.load\\(open\\('modules/openclaw-config.json'\\)\\); print\\('JSON is valid'\\)\")",
"mcp__zread__search_doc",
"mcp__zread__read_file",
"Bash(sudo podman:*)",
"Bash(curl:*)",
"Bash(sudo ls:*)",
"Bash(sudo systemctl:*)",
"Bash(sudo mkdir:*)",
"Bash(sudo chown:*)",
"Bash(sudo chmod:*)",
"Read(//var/lib/**)",
"Bash(sudo cat:*)",
"Bash(sudo journalctl:*)",
"Bash(sudo nginx:*)",
"Bash(systemctl status:*)",
"Bash(nix-instantiate:*)",
"Bash(sudo head:*)",
"Bash(ls:*)",
"Bash(systemctl show:*)",
"Bash(nix-store:*)",
"Read(//etc/**)",
"Bash(sudo iptables:*)",
"Bash(sudo find:*)",
"Bash(sudo grep:*)",
"Bash(find:*)",
"Bash(systemctl cat:*)",
"Bash(readlink:*)",
"Bash(nixos-option:*)",
"Bash(grep:*)",
"Bash(echo:*)",
"Bash(sudo systemd-tmpfiles:*)",
"Read(//var/run/**)",
"Bash(resolvectl status:*)",
"Bash(sudo -u forgejo nslookup google.com 2>&1 || echo \"nslookup failed, trying getent:\" && sudo -u forgejo getent hosts google.com 2>&1)",
"Bash(sudo -u gitea-runner podman run --rm --network bridge alpine:latest nslookup google.com 2>&1 || echo \"Test failed\")",
"Read(//nix/store/**)",
"Read(//home/ashie/.config/containers/**)",
"Bash(sudo -u gitea-runner -- cat ~/.config/containers/containers.conf 2>/dev/null || echo \"No user containers.conf\")",
"Bash(nix repl:*)",
"mcp__zread__get_repo_structure",
"Bash(git clone:*)",
"Bash(/nix/store/dcax4chmdjyqwvns9arqqg3kmf889kbq-forgejo-runner-12.7.2/bin/act_runner generate-config:*)",
"Bash(cat:*)",
"Bash(nix flake:*)",
"Bash(git commit:*)",
"Bash(git show:*)",
"Bash(systemctl list-units:*)",
"Bash(getent group:*)",
"Bash(man -P cat gitea-actions-runner)",
"Read(//run/current-system/sw/share/doc/man/**)",
"Bash(mcp__zread__get_repo_structure repo_name=\"penal-colony/gosearch\")",
"Bash(ssh:*)",
"Bash(nix eval:*)",
"Bash(sudo -u gitea-runner podman info 2>&1 | head -20)",
"Bash(forgejo:*)",
"Read(//home/ashie/.config/**)",
"Read(//run/secrets.d/150/rendered/**)",
"Bash(journalctl:*)"
"mcp__zread__read_file"
]
}
}

View file

@ -1,7 +1,6 @@
{ config, pkgs, lib, inputs, ... }: {
{ config, pkgs, lib, ... }: {
imports = [
./hardware-configuration.nix
inputs.kafka.nixosModules.default
];
# Workaround for https://github.com/NixOS/nix/issues/8502
@ -16,16 +15,6 @@
networking.hostName = "nixos";
networking.domain = "";
# === IPv6 ===
networking.interfaces.ens6.ipv6.addresses = [{
address = "2a01:239:484:9d00::1";
prefixLength = 80;
}];
networking.defaultGateway6 = {
address = "fe80::1";
interface = "ens6";
};
# === Firewall ===
networking.firewall = {
enable = true;
@ -94,20 +83,12 @@
git
nano
kitty.terminfo
claude-code
htop
tmux
headscale
];
nix.settings.experimental-features = [ "nix-command" "flakes" ];
# Allow unfree
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [
"open-webui"
"claude-code"
];
# === SearXNG ===
myModules.searxng = {
enable = true;
@ -120,22 +101,9 @@
myModules.nginx = {
enable = true;
email = "info@ashisgreat.xyz";
rateLimit = {
enable = true;
requests = 10;
burst = 20;
};
domains = {
"search.ashisgreat.xyz" = {
port = 8888;
# SearXNG sets its own CSP in settings.yml — omit at Nginx level to avoid conflicts
contentSecurityPolicy = null;
# Search engine — slightly more permissive for bot traffic
rateLimit.requests = 20;
rateLimit.burst = 40;
};
"search2.ashisgreat.xyz" = {
port = 8889;
};
};
};
@ -149,7 +117,6 @@
# === OpenClaw ===
myModules.openclaw-podman = {
enable = true;
superpowers.enable = true;
port = 18789;
domain = "openclaw.ashisgreat.xyz";
};
@ -158,135 +125,21 @@
sops.secrets.openclaw_discord_token = { };
sops.secrets.openclaw_zai_api_key = { };
sops.secrets.openclaw_brave_api_key = { };
sops.secrets.github_token = { };
sops.secrets.forgejo_url = { };
sops.secrets.forgejo_token = { };
sops.secrets.forgejo_user = { };
sops.secrets.tng_api_key = { };
sops.secrets.minimax_api_key = { };
sops.templates."openclaw.env" = {
content = ''
OPENCLAW_GATEWAY_TOKEN=local_bypass_token_for_openclaw_12345
DISCORD_TOKEN=${config.sops.placeholder.openclaw_discord_token}
ZAI_API_KEY=${config.sops.placeholder.openclaw_zai_api_key}
BRAVE_API_KEY=${config.sops.placeholder.openclaw_brave_api_key}
GITHUB_TOKEN=${config.sops.placeholder.github_token}
FORGEJO_URL=${config.sops.placeholder.forgejo_url}
FORGEJO_TOKEN=${config.sops.placeholder.forgejo_token}
FORGEJO_USER=${config.sops.placeholder.forgejo_user}
TNG_API_KEY=${config.sops.placeholder.tng_api_key}
MINIMAX_API_KEY=${config.sops.placeholder.minimax_api_key}
'';
};
# === gosearch (kafka) config template ===
sops.templates."kafka-config.toml" = {
content = ''
[server]
port = 8889
http_timeout = "10s"
base_url = "https://search2.ashisgreat.xyz"
[engines]
local_ported = ["wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing"]
[engines.brave]
api_key = "${config.sops.placeholder.openclaw_brave_api_key}"
[cache]
address = "127.0.0.1:6379"
db = 1
default_ttl = "5m"
[cors]
allowed_origins = ["*"]
[rate_limit]
requests = 30
window = "1m"
cleanup_interval = "5m"
[global_rate_limit]
requests = 0
window = "1m"
[burst_rate_limit]
burst = 0
burst_window = "5s"
sustained = 0
sustained_window = "1m"
'';
};
# === kafka Metasearch Engine ===
services.kafka = {
enable = true;
port = 8889;
baseUrl = "https://search2.ashisgreat.xyz";
config = config.sops.templates."kafka-config.toml".path;
};
# Separate service to fix kafka config permissions (runs as root)
systemd.services.kafka-fix-perms = {
description = "Fix kafka config file permissions";
wantedBy = [ "kafka.service" ];
partOf = [ "kafka.service" ];
before = [ "kafka.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.bash}/bin/bash -c 'chmod g+r /run/secrets/rendered/kafka-config.toml && chgrp kafka /run/secrets/rendered/kafka-config.toml'";
RemainAfterExit = true;
};
};
# Hardening for kafka service
systemd.services.kafka = {
path = with pkgs; [ inputs.kafka.packages.${pkgs.system}.default ];
serviceConfig = {
# Fix: binary is named kafka, not searxng-go
ExecStart = lib.mkForce "${inputs.kafka.packages.${pkgs.system}.default}/bin/kafka -config /run/secrets/rendered/kafka-config.toml";
# Need root group to read /run/secrets/rendered/kafka-config.toml
SupplementaryGroups = [ "root" ];
# Capability bounds
CapabilityBoundingSet = [ "" ];
AmbientCapabilities = [ "" ];
# Filesystem
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ "/var/lib/kafka" "/run/secrets" ];
PrivateTmp = true;
# Network
PrivateDevices = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
# Process isolation
ProtectProc = "invisible";
ProcSubset = "pid";
NoNewPrivileges = true;
ProtectClock = true;
ProtectHostname = true;
# System call filtering
SystemCallFilter = [ "@system-service" "~@privileged" ];
SystemCallArchitectures = "native";
# Memory
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
# Resource limits
RestrictNamespaces = true;
LockPersonality = true;
RemoveIPC = true;
};
};
# === Vaultwarden ===
myModules.vaultwarden = {
enable = true;
@ -314,54 +167,12 @@
};
# === CrowdSec ===
myModules.crowdsec = {
enable = true;
enableBouncer = false; # Temporarily disabled due to StateDirectory conflict
};
# === Headscale (Self-hosted Tailscale) ===
myModules.headscale = {
enable = true;
domain = "vpn.ashisgreat.xyz";
# OIDC not enabled by default — use pre-shared auth keys:
# headscale apikeys create
# tailscale up --login-server=https://vpn.ashisgreat.xyz --authkey=<key>
#
# To enable OIDC login (Google, GitHub, etc.):
# oidc.enable = true;
# oidc.issuer = "https://accounts.google.com";
# oidc.clientId = "your-client-id";
# oidc.clientSecret = config.sops.placeholder.headscale_oidc_secret;
# And add headscale_oidc_secret to your secrets.yaml
};
# === Netdata (System Monitoring) ===
myModules.netdata = {
enable = true;
domain = "netdata.ashisgreat.xyz";
};
# === Tailscale Client ===
services.tailscale.enable = true;
# === OpenWeb UI ===
myModules.open-webui = {
enable = true;
port = 8081;
domain = "ai.ashisgreat.xyz";
ollamaUrl = "http://100.64.0.1:11434";
};
myModules.crowdsec.enable = true;
# === Backups (Restic + B2) ===
myModules.backup = {
enable = true;
repository = "b2:nixos-vps-backup2";
paths = [
"/var/lib/vaultwarden" # Vaultwarden SQLite DB and attachments
"/var/backup/vaultwarden" # Vaultwarden built-in backup snapshots
"/var/lib/private/AdGuardHome" # AdGuard Home config, filters, query logs
"/var/lib/sops-nix" # SOPS age key (critical — decrypts all secrets)
"/var/lib/crowdsec" # CrowdSec state, decisions, custom parsers
];
paths = [ "/var/lib/bitwarden_rs" "/var/backup/vaultwarden" ];
};
}

View file

@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!-- The Harrow — Favicon -->
<!-- Central needle -->
<rect x="29" y="6" width="6" height="52" fill="#E63946"/>
<!-- Scratches — left -->
<line x1="29" y1="22" x2="12" y2="18" stroke="#E63946" stroke-width="4" stroke-linecap="square"/>
<line x1="29" y1="36" x2="8" y2="40" stroke="#E63946" stroke-width="4" stroke-linecap="square"/>
<line x1="29" y1="48" x2="18" y2="54" stroke="#E63946" stroke-width="4" stroke-linecap="square"/>
<!-- Scratches — right -->
<line x1="35" y1="26" x2="52" y2="20" stroke="#E63946" stroke-width="4" stroke-linecap="square"/>
<line x1="35" y1="40" x2="56" y2="44" stroke="#E63946" stroke-width="4" stroke-linecap="square"/>
<line x1="35" y1="52" x2="46" y2="58" stroke="#E63946" stroke-width="4" stroke-linecap="square"/>
<!-- Needle tip -->
<polygon points="29,58 35,58 32,64" fill="#E63946"/>
</svg>

Before

Width:  |  Height:  |  Size: 937 B

View file

@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<!-- The Harrow — Penal Colony Git Forge -->
<!-- Central needle -->
<rect x="244" y="40" width="24" height="432" fill="#E63946"/>
<!-- Etched scratches — left -->
<line x1="244" y1="180" x2="120" y2="160" stroke="#E63946" stroke-width="14" stroke-linecap="square"/>
<line x1="244" y1="260" x2="80" y2="280" stroke="#E63946" stroke-width="14" stroke-linecap="square"/>
<line x1="244" y1="340" x2="140" y2="380" stroke="#E63946" stroke-width="14" stroke-linecap="square"/>
<!-- Etched scratches — right -->
<line x1="268" y1="200" x2="392" y2="170" stroke="#E63946" stroke-width="14" stroke-linecap="square"/>
<line x1="268" y1="290" x2="430" y2="310" stroke="#E63946" stroke-width="14" stroke-linecap="square"/>
<line x1="268" y1="370" x2="370" y2="410" stroke="#E63946" stroke-width="14" stroke-linecap="square"/>
<!-- Needle tip — descending point -->
<polygon points="244,472 268,472 256,510" fill="#E63946"/>
</svg>

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!-- The Harrow — Favicon -->
<!-- Central needle -->
<rect x="29" y="6" width="6" height="52" fill="#E63946"/>
<!-- Scratches — left -->
<line x1="29" y1="22" x2="12" y2="18" stroke="#E63946" stroke-width="4" stroke-linecap="square"/>
<line x1="29" y1="36" x2="8" y2="40" stroke="#E63946" stroke-width="4" stroke-linecap="square"/>
<line x1="29" y1="48" x2="18" y2="54" stroke="#E63946" stroke-width="4" stroke-linecap="square"/>
<!-- Scratches — right -->
<line x1="35" y1="26" x2="52" y2="20" stroke="#E63946" stroke-width="4" stroke-linecap="square"/>
<line x1="35" y1="40" x2="56" y2="44" stroke="#E63946" stroke-width="4" stroke-linecap="square"/>
<line x1="35" y1="52" x2="46" y2="58" stroke="#E63946" stroke-width="4" stroke-linecap="square"/>
<!-- Needle tip -->
<polygon points="29,58 35,58 32,64" fill="#E63946"/>
</svg>

Before

Width:  |  Height:  |  Size: 937 B

View file

@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<!-- The Harrow — Penal Colony Git Forge -->
<!-- Central needle -->
<rect x="244" y="40" width="24" height="432" fill="#E63946"/>
<!-- Etched scratches — left -->
<line x1="244" y1="180" x2="120" y2="160" stroke="#E63946" stroke-width="14" stroke-linecap="square"/>
<line x1="244" y1="260" x2="80" y2="280" stroke="#E63946" stroke-width="14" stroke-linecap="square"/>
<line x1="244" y1="340" x2="140" y2="380" stroke="#E63946" stroke-width="14" stroke-linecap="square"/>
<!-- Etched scratches — right -->
<line x1="268" y1="200" x2="392" y2="170" stroke="#E63946" stroke-width="14" stroke-linecap="square"/>
<line x1="268" y1="290" x2="430" y2="310" stroke="#E63946" stroke-width="14" stroke-linecap="square"/>
<line x1="268" y1="370" x2="370" y2="410" stroke="#E63946" stroke-width="14" stroke-linecap="square"/>
<!-- Needle tip — descending point -->
<polygon points="244,472 268,472 256,510" fill="#E63946"/>
</svg>

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1,404 +0,0 @@
# gosearch Integration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Integrate gosearch (kafka) metasearch engine into the NixOS VPS configuration as a hardened systemd service with Nginx reverse proxy and Valkey caching.
**Architecture:** Add gosearch as a flake input, import its native NixOS module, configure service to run on localhost:8889, connect to shared Valkey cache (DB index 1), and expose via Nginx at search2.ashisgreat.xyz.
**Tech Stack:** NixOS flakes, systemd, Podman (for Valkey), Nginx, SOPS for secrets
---
## File Structure
**Files to create:**
- None (all modifications to existing files)
**Files to modify:**
| File | Purpose |
|------|---------|
| `flake.nix` | Add gosearch flake input |
| `configuration.nix` | Import kafka module, configure service, add SOPS template, add systemd hardening, add nginx domain |
| `modules/searxng.nix` | Add Valkey host port forward for gosearch access |
---
## Task 1: Add gosearch as Flake Input
**Files:**
- Modify: `flake.nix`
- [ ] **Step 1: Add gosearch input to flake.nix**
The gosearch input goes in the `inputs` attribute set. Place it alphabetically after existing inputs.
```nix
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
sops-nix.url = "github:Mic92/sops-nix";
gosearch.url = "git+ssh://forgejo@git.ashisgreat.xyz:2222/penal-colony/gosearch.git";
openclaw-superpowers = {
url = "github:ArchieIndian/openclaw-superpowers";
flake = false;
};
};
```
- [ ] **Step 2: Verify flake update works**
Run: `nix flake update --commit-lockfile`
Expected: Lockfile updates successfully, no errors
**Note:** The gosearch flake has a placeholder `vendorHash = ""` in its flake.nix. During the first `nixos-rebuild build`, Nix will fail with the correct hash. This is expected — you'll need to copy that hash into the gosearch flake and run `nix flake update` again. Alternatively, if gosearch's repository has been updated with the correct hash, this step will work immediately.
- [ ] **Step 3: Commit**
```bash
git add flake.nix flake.lock
git commit -m "feat(gosearch): add gosearch flake input"
```
---
## Task 2: Import kafka Module and Configure Basic Service
**Files:**
- Modify: `configuration.nix`
- [ ] **Step 1: Import the kafka module**
Add to the `imports` array in `configuration.nix` (around line 3, after `./hardware-configuration.nix`):
```nix
imports = [
./hardware-configuration.nix
inputs.gosearch.nixosModules.default # Add this line
];
```
- [ ] **Step 2: Add basic service configuration**
Add after the OpenWeb UI configuration block (around line 240, before `# === Backups ===`):
```nix
# === gosearch (kafka) Metasearch Engine ===
services.kafka = {
enable = true;
port = 8889;
baseUrl = "https://search2.ashisgreat.xyz";
config = config.sops.templates."kafka-config.toml".path;
};
```
- [ ] **Step 3: Test configuration build**
Run: `nixos-rebuild build --flake .#nixos`
Expected: Build succeeds (may have warnings about missing config file, that's expected at this stage)
- [ ] **Step 4: Commit**
```bash
git add configuration.nix
git commit -m "feat(gosearch): import kafka module and configure basic service"
```
---
## Task 3: Create SOPS Config Template
**Files:**
- Modify: `configuration.nix`
- [ ] **Step 1: Add kafka-config.toml SOPS template**
Add the SOPS template after the OpenClaw SOPS templates block (around line 175):
```nix
# gosearch/kafka config template
sops.templates."kafka-config.toml" = {
content = ''
[server]
port = 8889
http_timeout = "10s"
base_url = "https://search2.ashisgreat.xyz"
[engines]
local_ported = ["wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing"]
[engines.brave]
api_key = "${config.sops.placeholder.openclaw_brave_api_key}"
[cache]
address = "127.0.0.1:6379"
db = 1
default_ttl = "5m"
[cors]
allowed_origins = ["*"]
[rate_limit]
requests = 30
window = "1m"
cleanup_interval = "5m"
[global_rate_limit]
requests = 0
window = "1m"
[burst_rate_limit]
burst = 0
burst_window = "5s"
sustained = 0
sustained_window = "1m"
'';
};
```
- [ ] **Step 2: Test configuration build**
Run: `nixos-rebuild build --flake .#nixos`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add configuration.nix
git commit -m "feat(gosearch): add SOPS config template for kafka"
```
---
## Task 4: Add Systemd Hardening
**Files:**
- Modify: `configuration.nix`
- [ ] **Step 1: Add systemd service hardening**
Add the hardening configuration after the kafka service block (added in Task 2):
```nix
# Hardening for kafka service
systemd.services.kafka.serviceConfig = {
# Capability bounds
CapabilityBoundingSet = [ "" ];
AmbientCapabilities = [ "" ];
# Filesystem
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ "/var/lib/kafka" ];
PrivateTmp = true;
# Network
PrivateDevices = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
# Process isolation
ProtectProc = "invisible";
ProcSubset = "pid";
NoNewPrivileges = true;
ProtectClock = true;
ProtectHostname = true;
# System call filtering
SystemCallFilter = [ "@system-service" "~@privileged" ];
SystemCallArchitectures = "native";
# Memory
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
# Resource limits
RestrictNamespaces = true;
LockPersonality = true;
RemoveIPC = true;
};
```
- [ ] **Step 2: Test configuration build**
Run: `nixos-rebuild build --flake .#nixos`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add configuration.nix
git commit -m "feat(gosearch): add systemd hardening for kafka service"
```
---
## Task 5: Add Nginx Domain Configuration
**Files:**
- Modify: `configuration.nix`
- [ ] **Step 1: Add search2 domain to nginx configuration**
Find the `myModules.nginx.domains` section (around line 127) and add the new domain after the search.ashisgreat.xyz entry:
```nix
myModules.nginx = {
enable = true;
email = "info@ashisgreat.xyz";
rateLimit = {
enable = true;
requests = 10;
burst = 20;
};
domains = {
"search.ashisgreat.xyz" = {
port = 8888;
contentSecurityPolicy = null;
rateLimit.requests = 20;
rateLimit.burst = 40;
};
"search2.ashisgreat.xyz" = {
port = 8889;
};
};
};
```
- [ ] **Step 2: Test configuration build**
Run: `nixos-rebuild build --flake .#nixos`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add configuration.nix
git commit -m "feat(gosearch): add search2 domain to nginx"
```
---
## Task 6: Add Valkey Host Port Forward
**Files:**
- Modify: `modules/searxng.nix`
- [ ] **Step 1: Add port mapping to searxng-valkey container**
Find the `searxng-valkey` container definition in `modules/searxng.nix` (around line 94) and add the `ports` option:
```nix
virtualisation.oci-containers.containers."searxng-valkey" = {
image = "docker.io/valkey/valkey:alpine";
cmd = [ "valkey-server" "--save" "" "--appendonly" "no" ];
extraOptions = [
"--network=searxng-net"
"--network-alias=valkey"
];
ports = [ "127.0.0.1:6379:6379" ]; # Add this line for gosearch access
};
```
- [ ] **Step 2: Test configuration build**
Run: `nixos-rebuild build --flake .#nixos`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add modules/searxng.nix
git commit -m "feat(gosearch): add Valkey host port forward for local access"
```
---
## Task 7: Deploy and Verify
**Files:**
- None (deployment and testing)
- [ ] **Step 1: Dry-run rebuild to verify configuration**
Run: `nixos-rebuild build --flake .#nixos`
Expected: Build completes successfully with no errors
- [ ] **Step 2: Apply configuration**
Run: `sudo nixos-rebuild switch --flake .#nixos`
Expected: Configuration applies successfully, services start
- [ ] **Step 3: Verify kafka service is running**
Run: `systemctl status kafka`
Expected: Service shows as `active (running)`
- [ ] **Step 4: Verify systemd hardening is applied**
Run: `systemctl show kafka | grep -E 'ProtectSystem|ProtectHome|PrivateTmp|NoNewPrivileges'`
Expected: All hardening options show `yes`
- [ ] **Step 5: Verify web interface responds locally**
Run: `curl -I http://localhost:8889`
Expected: HTTP 200 OK response
- [ ] **Step 6: Verify health endpoint**
Run: `curl http://localhost:8889/healthz`
Expected: `OK` response
- [ ] **Step 7: Verify nginx proxy works**
Run: `curl -I https://search2.ashisgreat.xyz`
Expected: HTTP 200 OK response via nginx
- [ ] **Step 8: Test search functionality**
Run: `curl -s "https://search2.ashisgreat.xyz/search?q=nixos&format=json" | jq -e '.results | length > 0'`
Expected: Output is `true` (indicating search returned non-empty results)
**Alternative (without jq):** Run: `curl -s "https://search2.ashisgreat.xyz/search?q=nixos&format=json" | grep -o '"results":\s*\[' | wc -l`
Expected: Output is `1` or more (results array is present)
- [ ] **Step 9: Check kafka logs for Valkey connection**
Run: `journalctl -u kafka -n 50`
Expected: No errors about Valkey connection, cache operations visible
- [ ] **Step 10: Commit final deployment**
```bash
git add -A
git commit -m "feat(gosearch): complete gosearch integration - service running and verified"
```
---
## Rollback Procedure (if issues occur)
If deployment fails:
```bash
# Rollback to previous generation
sudo nixos-rebuild switch --rollback
# Or list generations and select a specific one
sudo nixos-rebuild list-generations
sudo nixos-rebuild switch --profile /nix/var/nix/profiles/system --rollback
```
---
## Verification Checklist
After completing all tasks:
- [ ] `nixos-rebuild build` succeeds without errors
- [ ] `systemctl status kafka` shows active (running)
- [ ] `systemctl show kafka | grep ProtectSystem` returns `ProtectSystem=yes`
- [ ] `curl -I http://localhost:8889` returns HTTP 200
- [ ] `curl -I https://search2.ashisgreat.xyz` returns HTTP 200
- [ ] `curl "https://search2.ashisgreat.xyz/search?q=nixos&format=json"` returns results
- [ ] `journalctl -u kafka` shows no Valkey connection errors
- [ ] `curl http://localhost:8889/healthz` returns `OK`

View file

@ -1,200 +0,0 @@
# Netdata Module Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add Netdata real-time monitoring module, accessible only from Tailscale network.
**Architecture:** Create a NixOS module following existing patterns. Netdata binds to 0.0.0.0 for direct Tailscale access; nginx reverse proxy with `internalOnly` restricts domain access to Tailscale IPs.
**Tech Stack:** NixOS module system, services.netdata, nginx reverse proxy
---
## Task 1: Create Netdata Module
**Files:**
- Create: `modules/netdata.nix`
- [ ] **Step 1: Create the module file with options**
```nix
# Netdata Module
# Provides: Real-time system monitoring dashboard
#
# Usage:
# myModules.netdata = {
# enable = true;
# domain = "netdata.example.com";
# };
#
# Access is restricted to Tailscale network only via nginx internalOnly.
{
config,
lib,
...
}:
let
cfg = config.myModules.netdata;
in
{
options.myModules.netdata = {
enable = lib.mkEnableOption "Netdata real-time monitoring";
domain = lib.mkOption {
type = lib.types.str;
example = "netdata.example.com";
description = "Public domain name for Netdata dashboard";
};
port = lib.mkOption {
type = lib.types.port;
default = 19999;
description = "Internal port for Netdata to listen on";
};
};
config = lib.mkIf cfg.enable {
services.netdata = {
enable = true;
config = {
global = {
"bind to" = "0.0.0.0:${toString cfg.port}";
};
};
};
# Nginx reverse proxy - restricted to Tailscale network
myModules.nginx.domains.${cfg.domain} = {
port = cfg.port;
internalOnly = true;
contentSecurityPolicy = null;
};
};
}
```
- [ ] **Step 2: Verify file syntax**
Run: `nix-instantiate --parse modules/netdata.nix`
Expected: No parse errors
- [ ] **Step 3: Commit**
```bash
git add modules/netdata.nix
git commit -m "feat(modules): add Netdata monitoring module
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## Task 2: Register Module in Default Imports
**Files:**
- Modify: `modules/default.nix`
- [ ] **Step 1: Add netdata.nix to imports**
Add `./netdata.nix` to the imports list in `modules/default.nix`:
```nix
# Module exports
{
imports = [
./system.nix
./podman.nix
./nginx.nix
./searxng.nix
./openclaw-podman.nix
./vaultwarden.nix
./crowdsec.nix
./backup.nix
./adguard.nix
./forgejo.nix
./headscale.nix
./open-webui.nix
./netdata.nix
];
}
```
- [ ] **Step 2: Verify module is importable**
Run: `nix-instantiate --parse modules/default.nix`
Expected: No parse errors
- [ ] **Step 3: Commit**
```bash
git add modules/default.nix
git commit -m "feat(modules): register netdata module in default imports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## Task 3: Enable Module in configuration.nix
**Files:**
- Modify: `configuration.nix`
- [ ] **Step 1: Add Netdata configuration block**
Add the following to `configuration.nix` after the Headscale section (around line 221):
```nix
# === Netdata (System Monitoring) ===
myModules.netdata = {
enable = true;
domain = "netdata.ashisgreat.xyz";
};
```
- [ ] **Step 2: Verify configuration syntax**
Run: `nix-instantiate --parse configuration.nix`
Expected: No parse errors
- [ ] **Step 3: Commit**
```bash
git add configuration.nix
git commit -m "feat(config): enable Netdata monitoring
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## Task 4: Build and Verify
**Files:**
- None (verification only)
- [ ] **Step 1: Dry-run build to verify configuration**
Run: `nixos-rebuild build --flake .#nixos`
Expected: Build succeeds without errors
- [ ] **Step 2: Verify Netdata service configuration**
Run: `nix eval .#nixos.config.services.netdata.enable`
Expected: `true`
- [ ] **Step 3: Verify nginx domain configuration**
Run: `nix eval .#nixos.config.myModules.nginx.domains --json | grep -A5 netdata`
Expected: Shows netdata.ashisgreat.xyz with internalOnly: true
---
## Deployment Notes
After completing the plan:
1. Deploy with: `sudo nixos-rebuild switch --flake .#nixos`
2. Access at: `https://netdata.ashisgreat.xyz` (only from Tailscale-connected devices)
3. Direct access: `http://<tailscale-ip>:19999`

View file

@ -1,134 +0,0 @@
# OpenWeb UI Module Design
## Overview
Add OpenWeb UI as a native NixOS service, connecting to Ollama running on the user's PC via Headscale at `http://100.64.0.1:11434`.
## Architecture
A thin wrapper module around the native `services.open-webui` NixOS module that:
1. Configures connection to the remote Ollama instance
2. Integrates with the existing nginx module for HTTPS
3. Follows established module patterns (vaultwarden, forgejo)
## Module Options
```nix
myModules.open-webui = {
enable = true;
port = 8080;
domain = "ai.ashisgreat.xyz";
ollamaUrl = "http://100.64.0.1:11434";
};
```
## Components
### 1. Module File: `modules/open-webui.nix`
```nix
{
config,
lib,
...
}:
let
cfg = config.myModules.open-webui;
in
{
options.myModules.open-webui = {
enable = lib.mkEnableOption "OpenWeb UI for LLMs";
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Port to expose OpenWeb UI on localhost";
};
domain = lib.mkOption {
type = lib.types.str;
example = "ai.example.com";
description = "Public domain name for OpenWeb UI";
};
ollamaUrl = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:11434";
example = "http://100.64.0.1:11434";
description = "URL of the Ollama API endpoint";
};
};
config = lib.mkIf cfg.enable {
services.open-webui = {
enable = true;
port = cfg.port;
host = "127.0.0.1";
environment = {
OLLAMA_API_BASE_URL = cfg.ollamaUrl;
WEBUI_URL = "https://${cfg.domain}";
};
environmentFile = config.sops.templates."openwebui.env".path;
};
# SOPS template for secrets
sops.templates."openwebui.env" = {
content = ''
WEBUI_SECRET_KEY=${config.sops.placeholder.openwebui_secret_key}
'';
};
sops.secrets.openwebui_secret_key = { };
# Nginx configuration
myModules.nginx.domains.${cfg.domain} = {
port = cfg.port;
extraConfig = ''
client_max_body_size 100M;
'';
contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss: https:; frame-ancestors 'self'";
};
};
}
```
### 2. Update `modules/default.nix`
Add `./open-webui.nix` to imports.
### 3. Update `configuration.nix`
```nix
myModules.open-webui = {
enable = true;
domain = "ai.ashisgreat.xyz";
ollamaUrl = "http://100.64.0.1:11434";
};
```
### 4. Add Secret to `secrets/secrets.yaml`
```yaml
openwebui_secret_key: <generated-secret>
```
## Files Changed
| File | Change |
|------|--------|
| `modules/open-webui.nix` | New module |
| `modules/default.nix` | Add import |
| `configuration.nix` | Enable and configure |
| `secrets/secrets.yaml` | Add `openwebui_secret_key` |
## Security Considerations
- Service binds to `127.0.0.1` only (nginx handles external access)
- `WEBUI_SECRET_KEY` stored encrypted in SOPS
- CSP headers configured for WebSocket support
- Large upload limit (100M) for model files if needed
## Dependencies
- `services.open-webui` (available in nixpkgs unstable)
- Existing nginx and SOPS infrastructure

View file

@ -1,210 +0,0 @@
# gosearch Integration Design
**Date:** 2026-03-21
**Status:** Approved
**Author:** Claude Code
## Overview
Integrate [gosearch](https://git.ashisgreat.xyz/penal-colony/gosearch) (kafka), a privacy-respecting Go-based metasearch engine, into the NixOS VPS configuration. The service will run as a hardened systemd service, connect to the existing Valkey cache, and be exposed via Nginx reverse proxy.
## Requirements
- Deploy gosearch using its native NixOS module
- Expose on `search2.ashisgreat.xyz`
- Connect to existing Valkey instance (shared with SearXNG)
- Enable Brave Search with API key
- Run on port `8889` (localhost only)
- Harden the service following systemd security best practices
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ User Request Flow │
│ Browser → search2.ashisgreat.xyz → Nginx → localhost:8889 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ systemd kafka service │
│ - User: kafka | Port: 8889 | Hardened systemd │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Configuration Layer │
│ /etc/kafka/config.toml (generated via sops template) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Dependencies │
│ - Valkey: 127.0.0.1:6379, DB index 1 │
│ - Brave API: from SOPS │
└─────────────────────────────────────────────────────────────┘
```
## Implementation
### 1. Add Flake Input
```nix
# flake.nix
inputs.gosearch.url = "git+ssh://forgejo@git.ashisgreat.xyz:2222/penal-colony/gosearch.git";
```
### 2. Import Module
```nix
# configuration.nix
imports = [
...
inputs.gosearch.nixosModules.default
];
```
### 3. Service Configuration
```nix
# configuration.nix
services.kafka = {
enable = true;
port = 8889;
baseUrl = "https://search2.ashisgreat.xyz";
config = config.sops.templates."kafka-config.toml".path;
};
```
### 4. SOPS Template
```nix
# configuration.nix
sops.templates."kafka-config.toml" = {
content = ''
[server]
port = 8889
http_timeout = "10s"
base_url = "https://search2.ashisgreat.xyz"
[engines]
local_ported = ["wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing"]
[engines.brave]
api_key = "${config.sops.placeholder.openclaw_brave_api_key}"
[cache]
address = "127.0.0.1:6379"
db = 1
default_ttl = "5m"
[cors]
allowed_origins = ["*"]
[rate_limit]
requests = 30
window = "1m"
cleanup_interval = "5m"
[global_rate_limit]
requests = 0
window = "1m"
[burst_rate_limit]
burst = 0
burst_window = "5s"
sustained = 0
sustained_window = "1m"
'';
};
```
### 5. Systemd Hardening
```nix
# configuration.nix
systemd.services.kafka.serviceConfig = {
# Capability bounds
CapabilityBoundingSet = [ "" ];
AmbientCapabilities = [ "" ];
# Filesystem
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ "/var/lib/kafka" ];
PrivateTmp = true;
# Network
PrivateDevices = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
# Process isolation
ProtectProc = "invisible";
ProcSubset = "pid";
NoNewPrivileges = true;
ProtectClock = true;
ProtectHostname = true;
# System call filtering
SystemCallFilter = [ "@system-service" "~@privileged" ];
SystemCallArchitectures = "native";
# Memory
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
# Resource limits
RestrictNamespaces = true;
LockPersonality = true;
RemoveIPC = true;
};
```
### 6. Nginx Configuration
Add to `myModules.nginx.domains`:
```nix
"search2.ashisgreat.xyz" = {
port = 8889;
};
```
### 7. Valkey Host Port Forward
Since gosearch runs as a systemd service (not in podman network), add a host port forward for Valkey:
```nix
# modules/searxng.nix - modify valkey container
virtualisation.oci-containers.containers."searxng-valkey" = {
...
ports = [ "127.0.0.1:6379:6379" ];
};
```
## Security Considerations
1. **Network Exposure**: Service binds to localhost only; Nginx is the only external access
2. **Secrets Management**: Brave API key from existing SOPS secret
3. **Cache Isolation**: Uses DB index 1 (SearXNG uses 0)
4. **Process Isolation**: Comprehensive systemd hardening applied
5. **Supply Chain**: Binary built from source via Nix flakes
## Files to Modify
1. `flake.nix` - Add gosearch input
2. `configuration.nix` - Import module, configure service, add SOPS template, add Nginx domain, add systemd hardening
3. `modules/searxng.nix` - Add Valkey host port forward
4. `secrets/secrets.yaml` - No changes needed (reusing existing Brave key)
## Testing Checklist
- [ ] Configuration builds: `nixos-rebuild build`
- [ ] Service starts: `systemctl status kafka`
- [ ] Service is hardened: `systemctl show kafka | grep -i protect`
- [ ] Web interface loads: `curl -I http://localhost:8889`
- [ ] Nginx proxy works: `curl -I https://search2.ashisgreat.xyz`
- [ ] Valkey connection works: check service logs
- [ ] Search returns results
- [ ] Health check: `curl http://localhost:8889/healthz`

View file

@ -1,86 +0,0 @@
# Netdata Module Design
**Date:** 2026-03-21
**Status:** Draft
## Overview
Add Netdata real-time monitoring to the NixOS VPS, accessible only from the Headscale/Tailscale network.
## Requirements
- Netdata monitoring service running on the VPS
- Accessible via nginx reverse proxy with automatic HTTPS
- Restricted to Tailscale network only (100.64.0.0/10) and localhost
- Direct access on Tailscale IP (port 19999) also available
## Implementation
### Module: `modules/netdata.nix`
Create a new module following the existing pattern.
**Header comment block:**
```nix
# Netdata Module
# Provides: Real-time system monitoring dashboard
#
# Usage:
# myModules.netdata = {
# enable = true;
# domain = "netdata.example.com";
# };
#
# Access is restricted to Tailscale network only via nginx internalOnly.
```
**Options:**
- `enable` - Enable Netdata monitoring
- `domain` - Domain for nginx reverse proxy (e.g., `netdata.ashisgreat.xyz`)
- `port` - Internal port (default: 19999), description: "Internal port for Netdata to listen on"
**Configuration:**
- Enable `services.netdata` with default settings
- Bind Netdata to `0.0.0.0` to allow direct Tailscale access (not just localhost)
- Register domain with `myModules.nginx.domains` using `internalOnly = true`
- Set `contentSecurityPolicy = null` - Netdata dashboard has its own CSP requirements
- No firewall changes needed (nginx handles external access, direct Tailscale access works via mesh network)
### Usage in configuration.nix
```nix
myModules.netdata = {
enable = true;
domain = "netdata.ashisgreat.xyz";
};
```
### Access Control
- **Via domain:** Only accessible from IPs in `100.64.0.0/10` (Tailscale) or `127.0.0.0/8` (localhost)
- **Direct Tailscale:** `http://<tailscale-ip>:19999` (Tailscale mesh handles access control)
### Backup Decision
Netdata metrics data is **not backed up**. Rationale:
- Metrics are ephemeral and regeneratable
- Historical data is downsampled over time (not critical)
- `/var/lib/netdata` excluded from backup paths
### Secrets
No SOPS secrets required. Netdata operates without authentication at the service level - access control is enforced via nginx/Tailscale network restrictions.
## Files Changed
| File | Action |
|------|--------|
| `modules/netdata.nix` | Create |
| `modules/default.nix` | Add import |
| `configuration.nix` | Enable module |
## Security
- No public internet access - blocked at nginx level
- No authentication required at Netdata level (network-level access control)
- Automatic HTTPS via Let's Encrypt

70
flake.lock generated
View file

@ -1,46 +1,12 @@
{
"nodes": {
"kafka": {
"inputs": {
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1774192623,
"narHash": "sha256-oSpeg5M/HnQrfZ9Rn2ciAkHqN1vHMm0zowosJ0+o4ok=",
"ref": "refs/heads/main",
"rev": "994d27ff7f5c7b5c37964cf134f4b4ac005051aa",
"revCount": 108,
"type": "git",
"url": "ssh://forgejo@git.ashisgreat.xyz:2222/penal-colony/kafka.git"
},
"original": {
"type": "git",
"url": "ssh://forgejo@git.ashisgreat.xyz:2222/penal-colony/kafka.git"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1773628058,
"narHash": "sha256-hpXH0z3K9xv0fHaje136KY872VT2T5uwxtezlAskQgY=",
"lastModified": 1773734432,
"narHash": "sha256-IF5ppUWh6gHGHYDbtVUyhwy/i7D261P7fWD1bPefOsw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f8573b9c935cfaa162dd62cc9e75ae2db86f85df",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1773821835,
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
"rev": "cda48547b432e8d3b18b4180ba07473762ec8558",
"type": "github"
},
"original": {
@ -50,7 +16,7 @@
"type": "github"
}
},
"nixpkgs_3": {
"nixpkgs_2": {
"locked": {
"lastModified": 1773507054,
"narHash": "sha256-Q8U5VXgrcxmCxPtCCJCIZkcAX3FCZwGh1GNVIXxMND0=",
@ -66,40 +32,22 @@
"type": "github"
}
},
"openclaw-superpowers": {
"flake": false,
"locked": {
"lastModified": 1773810424,
"narHash": "sha256-fGx2ZxjL+SW9e3Iejja0WJV5lLjDkdz6hKXs07EVREc=",
"owner": "ArchieIndian",
"repo": "openclaw-superpowers",
"rev": "db78b90144f966441f1946a910330be02f1cd360",
"type": "github"
},
"original": {
"owner": "ArchieIndian",
"repo": "openclaw-superpowers",
"type": "github"
}
},
"root": {
"inputs": {
"kafka": "kafka",
"nixpkgs": "nixpkgs_2",
"openclaw-superpowers": "openclaw-superpowers",
"nixpkgs": "nixpkgs",
"sops-nix": "sops-nix"
}
},
"sops-nix": {
"inputs": {
"nixpkgs": "nixpkgs_3"
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1773889674,
"narHash": "sha256-+ycaiVAk3MEshJTg35cBTUa0MizGiS+bgpYw/f8ohkg=",
"lastModified": 1773698643,
"narHash": "sha256-VCiDjE8kNs8uCAK73Ezk1r3fFuc4JepvW07YFqaN968=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "29b6519f3e0780452bca0ac0be4584f04ac16cc5",
"rev": "8237de83e8200d16fe0c4467b02a1c608ff28044",
"type": "github"
},
"original": {

View file

@ -3,18 +3,12 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
kafka.url = "git+ssh://forgejo@git.ashisgreat.xyz:2222/penal-colony/kafka.git";
sops-nix.url = "github:Mic92/sops-nix";
openclaw-superpowers = {
url = "github:ArchieIndian/openclaw-superpowers";
flake = false;
};
};
outputs = { self, nixpkgs, kafka, sops-nix, ... }@inputs: {
outputs = { self, nixpkgs, sops-nix, ... }@inputs: {
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = { inherit inputs; };
modules = [
./configuration.nix
./modules

View file

@ -85,7 +85,7 @@ in
services.adguardhome = {
enable = true;
host = "0.0.0.0";
host = "127.0.0.1";
port = cfg.port;
settings = {
dns = {
@ -96,16 +96,6 @@ in
querylog_enabled = true;
querylog_file_enabled = true;
statistics_enabled = true;
rewrites = [
{
domain = "*.ashisgreat.xyz";
answer = "100.64.0.3";
}
{
domain = "ashisgreat.xyz";
answer = "100.64.0.3";
}
];
};
tls = {
@ -169,28 +159,15 @@ in
networking.firewall.allowedTCPPorts = [ 853 ];
networking.firewall.allowedUDPPorts = [ 853 ];
# Allow Tailscale DNS requests
networking.firewall.interfaces."tailscale0" = {
allowedTCPPorts = [ 53 5353 ];
allowedUDPPorts = [ 53 5353 ];
};
# Nginx configuration (kept to satisfy ACME challenges for DoT certificates)
services.nginx.virtualHosts."${cfg.domain}" = {
enableACME = true;
forceSSL = true;
# Redirect standard DNS port 53 to AdGuard Home's 5353 for Tailscale clients
networking.firewall.extraCommands = ''
iptables -t nat -A PREROUTING -i tailscale0 -p udp --dport 53 -j REDIRECT --to-ports 5353
iptables -t nat -A PREROUTING -i tailscale0 -p tcp --dport 53 -j REDIRECT --to-ports 5353
iptables -I ts-input 3 -p tcp --dport ${toString cfg.port} -s 100.64.0.0/10 -j ACCEPT
'';
networking.firewall.extraStopCommands = ''
iptables -t nat -D PREROUTING -i tailscale0 -p udp --dport 53 -j REDIRECT --to-ports 5353 || true
iptables -t nat -D PREROUTING -i tailscale0 -p tcp --dport 53 -j REDIRECT --to-ports 5353 || true
'';
# Nginx reverse proxy for AdGuard Home Web UI and DoH (Tailscale only)
myModules.nginx.domains."${cfg.domain}" = {
port = cfg.port;
internalOnly = true; # Restrict access to Tailscale network and localhost only
contentSecurityPolicy = null; # AdGuard Home handles its own CSP
# Block all paths (no DoH or UI exposed via Nginx)
locations."/" = {
return = "404";
};
};
# Ensure nginx user can access ACME certs

View file

@ -11,8 +11,5 @@
./backup.nix
./adguard.nix
./forgejo.nix
./headscale.nix
./open-webui.nix
./netdata.nix
];
}

View file

@ -1,150 +1,130 @@
# Forgejo Module
# Provides: Self-hosted Git service (Fork of Gitea)
#
# Usage:
# myModules.forgejo = {
# enable = true;
# domain = "git.example.com";
# };
# Forgejo Module
# Provides: Self-hosted Git service (Fork of Gitea)
#
# Usage:
# myModules.forgejo = {
# enable = true;
# domain = "git.example.com";
# };
{
config,
lib,
pkgs,
...
}:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.myModules.forgejo;
in
{
options.myModules.forgejo = {
enable = lib.mkEnableOption "Forgejo Git service";
let
cfg = config.myModules.forgejo;
in
{
options.myModules.forgejo = {
enable = lib.mkEnableOption "Forgejo Git service";
port = lib.mkOption {
type = lib.types.port;
default = 3002;
description = "Internal port to run Forgejo on";
};
port = lib.mkOption {
type = lib.types.port;
default = 3002;
description = "Internal port to run Forgejo on";
};
domain = lib.mkOption {
domain = lib.mkOption {
type = lib.types.str;
example = "git.example.com";
description = "Public domain name for Forgejo";
};
disableRegistration = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Disable public user registration";
};
runner = {
enable = lib.mkEnableOption "Forgejo Actions Runner";
name = lib.mkOption {
type = lib.types.str;
example = "git.example.com";
description = "Public domain name for Forgejo";
default = config.networking.hostName;
description = "Name of the runner";
};
disableRegistration = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Disable public user registration";
tokenFile = lib.mkOption {
type = lib.types.path;
description = "Path to the token file (containing TOKEN=...)";
};
labels = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"native:host"
"ubuntu-latest:docker://node:20-bullseye"
"debian-latest:docker://node:20-bullseye"
];
description = "Labels for the runner";
};
};
};
runner = {
enable = lib.mkEnableOption "Forgejo Actions Runner";
name = lib.mkOption {
type = lib.types.str;
default = config.networking.hostName;
description = "Name of the runner";
config = lib.mkIf cfg.enable {
services.forgejo = {
enable = true;
database.type = "postgres";
settings = {
server = {
DOMAIN = cfg.domain;
ROOT_URL = "https://${cfg.domain}/";
HTTP_ADDR = "127.0.0.1";
HTTP_PORT = cfg.port;
SSH_PORT = 2222;
START_SSH_SERVER = true;
SSH_LISTEN_ADDR = "0.0.0.0";
# SSH Hardening
SSH_SERVER_KEY_EXCHANGES = "sntrup761x25519-sha512,curve25519-sha256,curve25519-sha256@libssh.org";
SSH_SERVER_CIPHERS = "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com";
SSH_SERVER_MACS = "hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com";
};
tokenFile = lib.mkOption {
type = lib.types.path;
description = "Path to the token file (containing TOKEN=...)";
service = {
DISABLE_REGISTRATION = cfg.disableRegistration;
};
labels = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"native:host"
"ubuntu-latest:docker://node:20-bullseye"
"debian-latest:docker://node:20-bullseye"
];
description = "Labels for the runner";
session = {
COOKIE_SECURE = true;
};
security = {
PASSWORD_COMPLEXITY = "lower,upper,digit,spec";
MIN_PASSWORD_LENGTH = 12;
};
};
};
config = lib.mkIf cfg.enable {
services.forgejo = {
# Nginx Reverse Proxy
myModules.nginx.domains."${cfg.domain}" = {
port = cfg.port;
extraConfig = ''
client_max_body_size 512M;
'';
};
# Open SSH port for Git
networking.firewall.allowedTCPPorts = [ 2222 ];
# Backups (Add Forgejo data to restic if backup module is enabled)
myModules.backup.paths = [
config.services.forgejo.stateDir
];
# Actions Runner
services.gitea-actions-runner = lib.mkIf cfg.runner.enable {
package = pkgs.forgejo-runner;
instances.default = {
enable = true;
database.type = "postgres";
customDir = "/var/lib/forgejo/custom";
name = cfg.runner.name;
url = "https://${cfg.domain}";
tokenFile = cfg.runner.tokenFile;
labels = cfg.runner.labels;
settings = {
server = {
DOMAIN = cfg.domain;
ROOT_URL = "https://${cfg.domain}/";
HTTP_ADDR = "127.0.0.1";
HTTP_PORT = cfg.port;
SSH_PORT = 2222;
START_SSH_SERVER = true;
SSH_LISTEN_ADDR = "0.0.0.0";
SSH_SERVER_KEY_EXCHANGES = "sntrup761x25519-sha512,curve25519-sha256,curve25519-sha256@libssh.org";
SSH_SERVER_CIPHERS = "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com";
SSH_SERVER_MACS = "hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com";
};
service = {
DISABLE_REGISTRATION = cfg.disableRegistration;
};
session = {
COOKIE_SECURE = true;
};
security = {
PASSWORD_COMPLEXITY = "lower,upper,digit,spec";
MIN_PASSWORD_LENGTH = 12;
};
"ui.meta" = {
AUTHOR = "Penal Colony";
DESCRIPTION = "The apparatus inscribes your code. Every commit is judged.";
};
"ui" = {
DEFAULT_THEME = "forgejo-auto";
APP_NAME = "The Harrow";
container = {
network = "bridge";
};
};
};
myModules.nginx.domains."${cfg.domain}" = {
port = cfg.port;
extraConfig = ''
client_max_body_size 512M;
'';
contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' wss://${cfg.domain}; frame-ancestors 'self'";
};
networking.firewall.allowedTCPPorts = [ 2222 ];
myModules.backup.paths = [
config.services.forgejo.stateDir
];
# Copy branding assets to custom directory (Forgejo serves from /assets/img/)
systemd.tmpfiles.rules = [
"d /var/lib/forgejo/custom/public/assets/img 0755 forgejo forgejo -"
"C+ /var/lib/forgejo/custom/public/assets/img/logo.svg - - - - ${toString ../custom/public/assets/img/logo.svg}"
"C+ /var/lib/forgejo/custom/public/assets/img/favicon.svg - - - - ${toString ../custom/public/assets/img/favicon.svg}"
"L /run/docker.sock - - - - /run/podman/podman.sock"
];
services.gitea-actions-runner = lib.mkIf cfg.runner.enable {
package = pkgs.forgejo-runner;
instances.default = {
enable = true;
name = cfg.runner.name;
url = "https://${cfg.domain}";
tokenFile = cfg.runner.tokenFile;
labels = cfg.runner.labels;
settings = {
container = {
network = "host";
};
};
};
};
# Fix: Bind mount Podman socket for gitea-runner
# With DynamicUser=true, SupplementaryGroups doesn't reliably work.
# BindPaths ensures the service can access the socket regardless of group membership.
systemd.services.gitea-runner-default = lib.mkIf cfg.runner.enable {
serviceConfig.BindPaths = [ "/run/podman/podman.sock" ];
};
};
}
};
}

View file

@ -1,151 +0,0 @@
# Headscale Module
# Provides: Self-hosted Tailscale control server (mesh VPN)
#
# Usage:
# myModules.headscale = {
# enable = true;
# domain = "vpn.example.com";
# };
#
# After deploying, generate an auth key to register devices:
# headscale apikeys create
#
# Then on each device:
# tailscale up --login-server=https://vpn.example.com --authkey=<key>
{
config,
lib,
pkgs,
...
}:
let
cfg = config.myModules.headscale;
in
{
options.myModules.headscale = {
enable = lib.mkEnableOption "Headscale (self-hosted Tailscale control server)";
domain = lib.mkOption {
type = lib.types.str;
example = "vpn.example.com";
description = "Public domain name for the Headscale control server";
};
port = lib.mkOption {
type = lib.types.port;
default = 8085;
description = "Internal port for Headscale to listen on";
};
oidc = {
enable = lib.mkEnableOption "OIDC authentication (e.g. Google, GitHub)";
issuer = lib.mkOption {
type = lib.types.str;
example = "https://accounts.google.com";
description = "OIDC issuer URL";
};
clientId = lib.mkOption {
type = lib.types.str;
description = "OIDC client ID (store in SOPS)";
};
clientSecret = lib.mkOption {
type = lib.types.str;
description = "OIDC client secret SOPS placeholder";
};
};
};
config = lib.mkIf cfg.enable {
services.headscale = {
enable = true;
settings = {
server_url = "https://${cfg.domain}";
listen_addr = "127.0.0.1:${toString cfg.port}";
grpc_listen_addr = "127.0.0.1:50443";
metrics_listen_addr = "127.0.0.1:9090";
# Use SQLite for simplicity (Postgres available for larger deployments)
database.sqlite.path = "/var/lib/headscale/db.sqlite";
# Private key for WireGuard — auto-generated on first run
private_key_path = "/var/lib/headscale/private.key";
# DERP relay servers (use Tailscale's public ones)
derp = {
urls = [
"https://controlplane.tailscale.com/derpmap/default"
];
auto_update = true;
};
# Disable built-in DERP server (use public relays only)
derp.server.enabled = false;
# Authentication
policy.mode = lib.mkIf cfg.oidc.enable "oidc";
oidc = lib.mkIf cfg.oidc.enable {
issuer = cfg.oidc.issuer;
client_id = cfg.oidc.clientId;
client_secret_path = config.sops.secrets.headscale_oidc_secret.path;
allowed_domains = [ ];
allowed_users = [ ];
scopes = [ "openid" "profile" "email" ];
};
# DNS configuration
dns = {
magic_dns = true;
base_domain = "headscale.net";
domains = [ ];
nameservers = {
global = [
"100.64.0.3"
];
};
override_local_dns = true;
};
# Logging
log.level = "info";
# Unix socket for headscale CLI
unix_socket = "/run/headscale/headscale.sock";
unix_socket_permission = "0770";
};
};
# OIDC secret from SOPS
sops.secrets.headscale_oidc_secret = lib.mkIf cfg.oidc.enable { };
# Nginx reverse proxy
myModules.nginx.domains."${cfg.domain}" = {
port = cfg.port;
extraConfig = ''
# Headscale needs WebSocket upgrade for client connections
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Larger buffers for Headscale
proxy_buffer_size 128k;
proxy_buffers 4 256k;
'';
# Headscale doesn't serve HTML — skip CSP
contentSecurityPolicy = null;
};
# Add Headscale data to backups
myModules.backup.paths = [
config.services.headscale.settings.database.sqlite.path
];
# Headscale needs its own group for socket permissions
users.groups.headscale = { };
};
}

View file

@ -1,63 +0,0 @@
# Netdata Module
# Provides: Real-time system monitoring dashboard
#
# Usage:
# myModules.netdata = {
# enable = true;
# domain = "netdata.example.com";
# };
#
# Access is restricted to Tailscale network only via nginx internalOnly.
{
config,
lib,
...
}:
let
cfg = config.myModules.netdata;
in
{
options.myModules.netdata = {
enable = lib.mkEnableOption "Netdata real-time monitoring";
domain = lib.mkOption {
type = lib.types.str;
example = "netdata.example.com";
description = "Public domain name for Netdata dashboard";
};
port = lib.mkOption {
type = lib.types.port;
default = 19999;
description = "Internal port for Netdata to listen on";
};
};
config = lib.mkIf cfg.enable {
myModules.nginx.enable = true; # Ensure nginx is enabled
services.netdata = {
enable = true;
config = {
global = {
"bind to" = "0.0.0.0:${toString cfg.port}";
};
};
};
# Nginx reverse proxy - restricted to Tailscale network
myModules.nginx.domains.${cfg.domain} = {
port = cfg.port;
internalOnly = true;
contentSecurityPolicy = null; # Netdata dashboard has its own CSP requirements, cannot be overridden
};
# Allow direct Tailscale access to Netdata port
networking.firewall.extraCommands = lib.mkAfter ''
iptables -I ts-input 3 -p tcp --dport ${toString cfg.port} -s 100.64.0.0/10 -j ACCEPT
'';
};
}

View file

@ -1,201 +1,118 @@
# Nginx Reverse Proxy Module
# Provides: Nginx with automatic Let's Encrypt certificates, security headers, and rate limiting
# Nginx Reverse Proxy Module
# Provides: Nginx with automatic Let's Encrypt certificates
#
# Usage:
# myModules.nginx = {
# enable = true;
# email = "your@email.com";
# domains = {
# "search.example.com" = {
# port = 8888;
# };
# };
# };
{
config,
lib,
pkgs,
...
}:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.myModules.nginx;
in
{
options.myModules.nginx = {
enable = lib.mkEnableOption "Nginx reverse proxy with Let's Encrypt";
let
cfg = config.myModules.nginx;
in
{
options.myModules.nginx = {
enable = lib.mkEnableOption "Nginx reverse proxy with Let's Encrypt";
email = lib.mkOption {
type = lib.types.str;
example = "admin@example.com";
description = "Email address for Let's Encrypt registration";
};
email = lib.mkOption {
type = lib.types.str;
example = "admin@example.com";
description = "Email address for Let's Encrypt registration";
};
rateLimit = {
enable = lib.mkEnableOption "Nginx rate limiting";
zone = lib.mkOption {
type = lib.types.str;
default = "10m";
description = "Size of the shared memory zone for rate limiting";
};
requests = lib.mkOption {
type = lib.types.int;
default = 10;
description = "Number of requests allowed per second";
};
burst = lib.mkOption {
type = lib.types.int;
default = 20;
description = "Maximum burst of requests allowed beyond the rate";
};
};
domains = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
port = lib.mkOption {
type = lib.types.port;
description = "Local port to proxy to";
};
extraConfig = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Extra Nginx config for this location";
};
contentSecurityPolicy = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'";
description = "Content-Security-Policy header value. Set to null to omit.";
};
internalOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Restrict access to Tailscale network and localhost only";
};
rateLimit = {
enable = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
description = "Enable rate limiting for this vhost.";
};
requests = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = "Number of requests allowed per second for this vhost.";
};
burst = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = "Burst size for this vhost.";
};
};
websockets = {
enable = lib.mkEnableOption "WebSocket proxy support for this domain";
};
extraLocations = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
proxyPass = lib.mkOption {
type = lib.types.str;
description = "Proxy target URL";
};
extraConfig = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Extra Nginx config for this location";
};
domains = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
port = lib.mkOption {
type = lib.types.port;
description = "Local port to proxy to";
};
extraConfig = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Extra Nginx config for this location";
};
extraLocations = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
proxyPass = lib.mkOption {
type = lib.types.str;
description = "Proxy target URL";
};
});
default = { };
description = "Additional location blocks";
extraConfig = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Extra Nginx config for this location";
};
};
});
default = { };
description = "Additional location blocks to add to this virtual host";
};
};
});
default = { };
description = "Domains to configure with their proxy targets";
};
};
config = lib.mkIf cfg.enable {
# Open HTTP/HTTPS ports
networking.firewall.allowedTCPPorts = [ 80 443 ];
# ACME (Let's Encrypt) configuration
security.acme = {
acceptTerms = true;
defaults.email = cfg.email;
certs = lib.mapAttrs' (domain: opts: {
name = domain;
value = { };
}) cfg.domains;
};
# Nginx configuration
services.nginx = {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
virtualHosts = lib.mapAttrs' (domain: opts: {
name = domain;
value = {
enableACME = true;
forceSSL = true;
locations = {
"/" = {
proxyPass = "http://127.0.0.1:${toString opts.port}";
extraConfig = opts.extraConfig;
};
};
});
default = { };
description = "Domains to configure with their proxy targets";
};
} // lib.mapAttrs' (locPath: locOpts: {
name = locPath;
value = {
proxyPass = locOpts.proxyPass;
extraConfig = locOpts.extraConfig;
};
}) opts.extraLocations;
};
}) cfg.domains;
};
config = lib.mkIf cfg.enable {
networking.firewall.allowedTCPPorts = [ 80 443 ];
security.acme = {
acceptTerms = true;
defaults.email = cfg.email;
certs = lib.mapAttrs' (domain: opts: {
name = domain;
value.webroot = "/var/lib/acme/acme-challenge";
}) cfg.domains;
};
services.nginx = {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
commonHttpConfig = lib.optionalString cfg.rateLimit.enable ''
limit_req_zone $binary_remote_addr zone=global:10m rate=${toString cfg.rateLimit.requests}r/s;
limit_conn_zone $binary_remote_addr zone=connlimit:10m;
''
+ ''
map $http_upgrade $connection_upgrade {
default upgrade;
''' close;
}
'';
virtualHosts = lib.mapAttrs' (domain: opts: {
name = domain;
value = {
useACMEHost = domain;
forceSSL = true;
extraConfig = ''
${lib.optionalString opts.internalOnly ''
allow 100.64.0.0/10;
allow 127.0.0.0/8;
deny all;
''}
add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
'' + lib.optionalString (opts.contentSecurityPolicy != null) ''
add_header Content-Security-Policy "${opts.contentSecurityPolicy}" always;
'';
locations = {
"/" = {
proxyPass = "http://127.0.0.1:${toString opts.port}";
extraConfig = opts.extraConfig
+ lib.optionalString opts.websockets.enable ''
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
''
+ lib.optionalString (if opts.rateLimit.enable != null then
opts.rateLimit.enable else cfg.rateLimit.enable) ''
limit_req zone=global burst=${toString (if opts.rateLimit.burst != null then opts.rateLimit.burst
else cfg.rateLimit.burst)} nodelay;
limit_conn connlimit 30;
limit_req_status 429;
'';
};
} // lib.mapAttrs' (locPath: locOpts: {
name = locPath;
value = {
proxyPass = locOpts.proxyPass;
extraConfig = locOpts.extraConfig;
};
}) opts.extraLocations;
};
}) cfg.domains;
};
users.users.nginx.extraGroups = [ "acme" ];
};
}
# Ensure nginx user can access ACME certs
users.users.nginx.extraGroups = [ "acme" ];
};
}

View file

@ -1,95 +0,0 @@
# OpenWebUI Podman Module
# Provides: Web interface for LLMs using official Docker image
#
# Usage:
# myModules.open-webui-podman = {
# enable = true;
# port = 9000;
# domain = "ai.example.com";
# ollamaUrl = "http://100.64.0.1:11434";
# };
{
config,
lib,
pkgs,
...
}:
let
cfg = config.myModules.open-webui-podman;
in
{
options.myModules.open-webui-podman = {
enable = lib.mkEnableOption "OpenWebUI for LLMs via Podman";
port = lib.mkOption {
type = lib.types.port;
default = 9000;
description = "Port to expose OpenWebUI on localhost";
};
domain = lib.mkOption {
type = lib.types.str;
example = "ai.example.com";
description = "Public domain name for OpenWebUI";
};
ollamaUrl = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:11434";
example = "http://100.64.0.1:11434";
description = "URL of the Ollama API endpoint";
};
};
config = lib.mkIf cfg.enable {
# Ensure podman is enabled
myModules.podman.enable = true;
# Podman container for OpenWebUI
virtualisation.oci-containers.containers.open-webui = {
image = "ghcr.io/open-webui/open-webui:main";
ports = ["127.0.0.1:${toString cfg.port}:8080"];
environment = {
OLLAMA_API_BASE_URL = cfg.ollamaUrl;
WEBUI_URL = "https://${cfg.domain}";
};
environmentFiles = [config.sops.templates."openwebui-podman.env".path];
volumes = [
"open-webui-data:/app/backend/data"
];
};
# SOPS template for secrets
sops.templates."openwebui-podman.env" = {
content = ''
WEBUI_SECRET_KEY=${config.sops.placeholder.openwebui_secret_key}
'';
};
sops.secrets.openwebui_secret_key = { };
# Nginx configuration
myModules.nginx.domains.${cfg.domain} = {
port = cfg.port;
extraConfig = ''
client_max_body_size 100M;
'';
# WebSocket support for /ws/
extraLocations."/ws/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
extraConfig = ''
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
'';
};
# Relaxed CSP for OpenWeb UI
contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss: https:; frame-ancestors 'self'";
};
};
}

View file

@ -1,83 +0,0 @@
# OpenWeb UI Module
# Provides: Web interface for LLMs (Ollama, OpenAI-compatible APIs)
#
# Usage:
# myModules.open-webui = {
# enable = true;
# port = 8080;
# domain = "ai.example.com";
# ollamaUrl = "http://100.64.0.1:11434"; # Remote Ollama via Tailscale/Headscale
# };
{
config,
lib,
...
}:
let
cfg = config.myModules.open-webui;
in
{
options.myModules.open-webui = {
enable = lib.mkEnableOption "OpenWeb UI for LLMs";
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Port to expose OpenWeb UI on localhost";
};
domain = lib.mkOption {
type = lib.types.str;
example = "ai.example.com";
description = "Public domain name for OpenWeb UI";
};
ollamaUrl = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:11434";
example = "http://100.64.0.1:11434";
description = "URL of the Ollama API endpoint";
};
};
config = lib.mkIf cfg.enable {
services.open-webui = {
enable = true;
port = cfg.port;
host = "127.0.0.1";
environment = {
OLLAMA_API_BASE_URL = cfg.ollamaUrl;
WEBUI_URL = "https://${cfg.domain}";
OPENAI_API_BASE_URL = "https://api.tng-chimera.ai/v1/";
};
environmentFile = config.sops.templates."openwebui.env".path;
};
# SOPS template for secrets
sops.templates."openwebui.env" = {
content = ''
WEBUI_SECRET_KEY=${config.sops.placeholder.openwebui_secret_key}
OPENAI_API_KEY=${config.sops.placeholder.tng_api_key}
'';
};
sops.secrets.openwebui_secret_key = { };
sops.secrets.tng_api_key = { };
# Nginx configuration
myModules.nginx.domains.${cfg.domain} = {
port = cfg.port;
extraConfig = ''
client_max_body_size 100M;
'';
# Disable rate limiting for OpenWebUI (loads many assets at once)
rateLimit.enable = false;
# Enable WebSocket support for Socket.IO
websockets.enable = true;
# Relaxed CSP for OpenWeb UI — needs unsafe-eval for some JS, WebSockets, external images
contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss: https: ws:; frame-ancestors 'self'";
};
};
}

View file

@ -4,8 +4,7 @@
"bind": "loopback",
"trustedProxies": ["::1", "127.0.0.1", "10.88.0.0/16", "10.89.0.0/16"],
"auth": {
"mode": "token",
"token": "${OPENCLAW_GATEWAY_TOKEN}"
"mode": "none"
},
"controlUi": {
"dangerouslyAllowHostHeaderOriginFallback": true,
@ -24,7 +23,7 @@
"agents": {
"defaults": {
"model": {
"primary": "minimax/MiniMax-M2.7"
"primary": "zai/glm-5"
}
}
},
@ -52,25 +51,6 @@
{ "id": "glm-4.5-air", "name": "GLM 4.5 Air", "contextWindow": 128000, "maxTokens": 131072 },
{ "id": "glm-4.7-flash", "name": "GLM 4.7 Flash", "contextWindow": 128000, "maxTokens": 131072 }
]
},
"tng": {
"baseUrl": "https://api.tng-chimera.ai/v1/",
"apiKey": "${TNG_API_KEY}",
"api": "openai-completions",
"models": [
{ "id": "tngtech/R1T2-Chimera-Speed", "name": "TNG Chimera Speed", "contextWindow": 65536, "maxTokens": 65536 }
]
},
"minimax": {
"baseUrl": "https://api.minimax.io/anthropic",
"apiKey": "${MINIMAX_API_KEY}",
"api": "anthropic-messages",
"models": [
{ "id": "MiniMax-M2.7", "name": "MiniMax M2.7", "contextWindow": 200000, "maxTokens": 32768, "reasoning": true },
{ "id": "MiniMax-M2.5", "name": "MiniMax M2.5", "contextWindow": 200000, "maxTokens": 32768, "reasoning": true },
{ "id": "MiniMax-M2.5-highspeed", "name": "MiniMax M2.5 Highspeed", "contextWindow": 200000, "maxTokens": 32768, "reasoning": true },
{ "id": "MiniMax-VL-01", "name": "MiniMax VL 01", "contextWindow": 200000, "maxTokens": 32768 }
]
}
}
}

View file

@ -1,5 +1,5 @@
# OpenClaw Podman Module
# Provides: AI Agent with Discord integration running in an isolated container
# Provides: AI Agent with Discord integration running in a container
#
# Usage:
# myModules.openclaw-podman = {
@ -12,7 +12,6 @@
config,
lib,
pkgs,
inputs,
...
}:
@ -23,15 +22,6 @@ in
options.myModules.openclaw-podman = {
enable = lib.mkEnableOption "OpenClaw AI Agent (Podman)";
superpowers = {
enable = lib.mkEnableOption "openclaw-superpowers extension";
src = lib.mkOption {
type = lib.types.path;
default = inputs.openclaw-superpowers;
description = "Path to openclaw-superpowers source";
};
};
port = lib.mkOption {
type = lib.types.port;
default = 18789;
@ -49,13 +39,7 @@ in
# Enable podman
myModules.podman.enable = true;
# Create directory for OpenClaw data
systemd.tmpfiles.rules = [
"d /var/lib/openclaw 0755 1000 1000 -" # Assuming node user is uid 1000
"d /var/lib/openclaw/local 0755 1000 1000 -" # For Go toolchain
];
# OpenClaw container (bridge network — isolated from host services)
# OpenClaw container
virtualisation.oci-containers.containers."openclaw" = {
image = "ghcr.io/openclaw/openclaw:latest";
ports = [ "127.0.0.1:${toString cfg.port}:8080" ];
@ -63,122 +47,12 @@ in
config.sops.templates."openclaw.env".path
];
volumes = [
"/var/lib/openclaw:/home/node/.openclaw"
"/var/lib/openclaw/local:/home/node/.local"
] ++ lib.optionals cfg.superpowers.enable [
"${cfg.superpowers.src}:/home/node/superpowers-src:ro"
"${./openclaw-config.json}:/home/node/.openclaw/openclaw.json:ro"
"openclaw-data:/home/node/.openclaw"
];
extraOptions = [
"--network=host"
];
};
# Copy the declarative config before starting the container
# This allows OpenClaw to safely write/rename the file at runtime without EBUSY errors
systemd.services."podman-openclaw".preStart = lib.mkBefore ''
mkdir -p /var/lib/openclaw/local
cp -f ${./openclaw-config.json} /var/lib/openclaw/openclaw.json
chown -R 1000:1000 /var/lib/openclaw
chmod -R u+rwX /var/lib/openclaw
${lib.optionalString cfg.superpowers.enable ''
# Setup openclaw-superpowers
mkdir -p /var/lib/openclaw/extensions
mkdir -p /var/lib/openclaw/skill-state
ln -sfT /home/node/superpowers-src/skills /var/lib/openclaw/extensions/superpowers
# Replicate install.sh stateful skill registration
REPO_SRC="${cfg.superpowers.src}"
for skill_file in $REPO_SRC/skills/openclaw-native/*/SKILL.md; do
[ -f "$skill_file" ] || continue
skill_name=$(basename $(dirname "$skill_file"))
# Check if stateful: true in frontmatter
if sed -n '2,/^---$/p' "$skill_file" | grep -q '^stateful: *true'; then
mkdir -p "/var/lib/openclaw/skill-state/$skill_name"
if [ ! -f "/var/lib/openclaw/skill-state/$skill_name/state.yaml" ]; then
echo "# Runtime state for $skill_name managed by openclaw-superpowers" > "/var/lib/openclaw/skill-state/$skill_name/state.yaml"
fi
fi
done
chown -R 1000:1000 /var/lib/openclaw/extensions /var/lib/openclaw/skill-state
''}
'';
# Set git email for the node user inside the container
systemd.services."openclaw-git-config" = {
description = "Configure git email for OpenClaw node user";
after = [ "podman-openclaw.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.podman}/bin/podman exec -u node openclaw git config --global user.email 'kafka@ashisgreat.xyz'";
RemainAfterExit = true;
};
};
# Go toolchain installation script
# Stored in /var/lib/openclaw and executed inside the container
environment.etc."openclaw/install-go.sh".source = pkgs.writeScript "install-go.sh" ''
#!/bin/bash
set -e
GO_URL="https://go.dev/dl/go1.24.1.linux-amd64.tar.gz"
GO_DIR="/home/node/.local/go"
if [ -d "$GO_DIR" ]; then
echo "Go already installed at $GO_DIR"
exit 0
fi
echo "Installing Go toolchain"
mkdir -p /home/node/.local
if command -v curl &> /dev/null; then
curl -fsSL "$GO_URL" | tar -C /home/node/.local -xzf -
elif command -v wget &> /dev/null; then
wget -qO- "$GO_URL" | tar -C /home/node/.local -xzf -
else
echo "ERROR - Neither curl nor wget available"
exit 1
fi
echo "Go installed successfully"
"$GO_DIR/bin/go" version
'';
# Go toolchain installation
# Downloads Go to a persistent volume for use inside the container
systemd.services."openclaw-go-setup" = {
description = "Install Go toolchain for OpenClaw";
after = [ "podman-openclaw.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
# Copy script to container-accessible location, then execute
ExecStart = "${pkgs.bash}/bin/bash -c 'cp /etc/openclaw/install-go.sh /var/lib/openclaw/ && chmod +x /var/lib/openclaw/install-go.sh && ${pkgs.podman}/bin/podman exec -u node openclaw /home/node/.openclaw/install-go.sh'";
RemainAfterExit = true;
};
};
# Optional: Install PyYAML inside the container on startup
# We do this as a postStart or a simple background loop if needed,
# but a better way is to ensure the image has it.
# Since we can't easily change the image here, we'll try to run a one-time pip install.
systemd.services."openclaw-superpowers-setup" = lib.mkIf cfg.superpowers.enable {
description = "One-time setup for OpenClaw superpowers (PyYAML and Cron)";
after = [ "podman-openclaw.service" "openclaw-go-setup.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.podman}/bin/podman exec -u node openclaw bash -c '\
python3 -m pip install --user PyYAML && \
for skill_file in /home/node/superpowers-src/skills/openclaw-native/*/SKILL.md; do \
[ -f \"$skill_file\" ] || continue; \
skill_name=$(basename $(dirname \"$skill_file\")); \
fm_cron=$(sed -n \"2,/^---$/p\" \"$skill_file\" | grep \"^cron:\" | sed \"s/^cron: *//\" | tr -d \"'\\\"\"); \
if [ -n \"$fm_cron\" ]; then \
openclaw cron add \"$skill_name\" \"$fm_cron\" || echo \"Cron add failed for $skill_name\"; \
fi; \
done'";
RemainAfterExit = true;
};
};
};
}

View file

@ -28,11 +28,5 @@ in
# Give main user access to podman
users.users.${mainUser}.extraGroups = [ "podman" ];
# Enable IP forwarding for container networking
boot.kernel.sysctl = {
"net.ipv4.ip_forward" = 1;
"net.ipv6.conf.all.forwarding" = 1;
};
};
}

View file

@ -98,7 +98,6 @@ in
"--network=searxng-net"
"--network-alias=valkey"
];
ports = [ "127.0.0.1:6379:6379" ];
};
# SearXNG Container

View file

@ -114,8 +114,6 @@ in
extraConfig = ''
client_max_body_size 128M;
'';
# Relaxed CSP for Vaultwarden — needs unsafe-eval for WebCrypto vault
contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://haveibeenpwned.com; font-src 'self'; connect-src 'self' wss://${cfg.domain} https://api.bitwarden.com https://haveibeenpwned.com; frame-ancestors 'self'";
extraLocations."/notifications/hub" = {
proxyPass = "http://127.0.0.1:${toString cfg.websocketPort}";
extraConfig = ''

View file

@ -9,14 +9,6 @@ b2_account_key: ENC[AES256_GCM,data:nfSNJ31CM7pJ1rR2UMF/jiCGopa0JPKAmpH5w3uovA==
restic_password: ENC[AES256_GCM,data:XXr82NqliAe5aHC5qX59d+8FpoZxMKxAXtRwvN0o,iv:bgz7Pw5hA/sUt9u8/mFqhpj4eHrlqhFzNJv97ZabMHQ=,tag:hRyX5+ZOnSKYIVPosAUp0A==,type:str]
adguard_client_phone: ENC[AES256_GCM,data:yN2u6zorCQJZYG+M,iv:VZz/mKOL/FS1Hpzfa3omjyO235K8FslNqRayhQ4S+5A=,tag:xBMYvNSn51CmiAPyHcUgUg==,type:str]
adguard_client_laptop: ENC[AES256_GCM,data:dMPcq76is01NQ/7j,iv:ZUu5HCF92eHmV2q+3FHwMQC89/HgjaPWOIgPiahsQSc=,tag:l+tQ9cOiDOt1r9FZzcW1Aw==,type:str]
forgejo_runner_token: ENC[AES256_GCM,data:ydIg2XfmK1RX4UXQa+mGDXO0n7Oo+wtqFC9FFFXrxMH6aGbSRRgzRw==,iv:QFzZSW00rR16jh8oxvhdq18/f6zakoxTqUSrf+QKgDk=,tag:YY/O1xtRoSV5qfmDXleXpA==,type:str]
forgejo_url: ENC[AES256_GCM,data:u1Y9lhI+QsRSu3O+9zBue+u0XIuGc08pN58=,iv:AnCEfoZb1YZHPk03MYXnKsG15/oOxo2ewcy09XqUqt4=,tag:0pgZ967pgrujfDqzgkwZdw==,type:str]
forgejo_token: ENC[AES256_GCM,data:/ugEQr0bqai0QPbW9FqdqQEBrF8bSqTtGtCswRP7BYfMRD0DLcaPhg==,iv:syrBNPw/uq3l9V6EVJ+axp3aM0R1tWz5odZy6jjh0PA=,tag:OJrqzJcJe6IrxZim1d5k4g==,type:str]
forgejo_user: ENC[AES256_GCM,data:Ralbwu6+6Htc2+I=,iv:dicPMRRriz6MVp0PtbezI6UcxdCmszp9PnTsJaHAzKk=,tag:g2mUMYmnDJZ42F+KVsvHhg==,type:str]
github_token: ENC[AES256_GCM,data:OAh6v6xrLr47ZdytdSR4uBpj6vJB8kJa8qc3eFBByK53nkrb3SUuBQ==,iv:GM/DpFSVl1CeQLX4tH2WxBuWBbI1YWzSsmvZK+2jdWM=,tag:lPQSjIlYbe3AczfDN3Nk+g==,type:str]
openwebui_secret_key: ENC[AES256_GCM,data:c1Mnc0juYBAmHap3f0G5vwRDUymYWb92nIk78Rw5ApkhhW4k4ifRccCsv0fU2TXNSwHCc7d6OeP60kJYEpr5ZA==,iv:wCqfwhn6WFv3A0asZnbPdBdmw24QdKbJE0BplWzq9CE=,tag:WU8Rlyf+CIL2uG8yWKHjcw==,type:str]
tng_api_key: ENC[AES256_GCM,data:KxfGd46SSp13zo1IxchDVq8dY7wTReyAS58JTIqJbv10YoXKY2fq4R4o6EaWSqyJC+k=,iv:QBVLbDDV94pwsHSngABiL17wVcHYNTUBtoOvQgg8Fcc=,tag:A/4Ieeb1nIKVEJ6FVL6gJQ==,type:str]
minimax_api_key: ENC[AES256_GCM,data:Y3jwwI7HvGKYZcBtgfjZWzs01rc7LMsBPlaVj1fnU4jBtF8wL0vVll9dTFENuJeCjmt35g1c5/7XtLe8T4s+A3w5LeRxAXsnGB7dM0mzxPf4EvKPCUdH+XuIpA4Iih1DbO0dO2H0/qSEg/Q9aucJusZ2mWcaT7meolccGp8=,iv:JkJYFP5N27QVkoLTOViIJYoHub66JV0ziY8ahzdL2lA=,tag:/aTyuzAdAtFCBoxg0SLYKw==,type:str]
sops:
age:
- recipient: age1rz4eyzmmtmua6s9cny3pjjwv80n4fpvhkwc4jzdd8vpre8zc5vtqfjtuy0
@ -28,7 +20,7 @@ sops:
SnI3Z3p6U0x1YzVUTHZ0RVh0cHBDSGMKbDXwp9MM9cL/9DgWPV/btH6iYgaVXmvw
Gk4IsH7zEWbS1kxIEapzBpIINTSQKZ30aPqwuspVKdSa8lsfi1X1jA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-03-21T23:33:40Z"
mac: ENC[AES256_GCM,data:CkwgWZPkxHolLemYlLsK2YD/Y7W+3m9443TNCjuvW9jP2hYzgjgL5UYxfltRZFkR2zL7Cy7yPsWOSbIKMY5wXMX6isOgtfPyTYG3rBZ8mR3l4iACeDtE58yVJXclXjcg81oJW8HUpgIcWPGrANtLqiqOdGxHd9e3eGaXZV+zfoU=,iv:mlEEmP0JMod+x0r2mzanyFQf2o7wxYxJoh10Pj2ebyM=,tag:DgNd3kG1G6L2mx4jwFZxWQ==,type:str]
lastmodified: "2026-03-18T18:14:16Z"
mac: ENC[AES256_GCM,data:FDCjGNY/a3edGiI6ML3CwjFDojkD1LEOF76NQ2ry12IEJDfpCHoD49cEaqCf53OmCiJiXGznYKTlH6gYyWVYesMMO95+CsZNeKNSP6PuCUdsUDekCULah9zq3OmK6//z3u9v+lKmOGINoC+MKaDfDWPZf1JVoAAXCdFweQNe/uk=,iv:3+Jc06fbow/nl9o2y7DgIohp3XmSzEofij/1o+ndSPk=,tag:PVDnqmttVStug+h5DxxX8g==,type:str]
unencrypted_suffix: _unencrypted
version: 3.12.1