This commit is contained in:
Bryan Ramos 2026-04-15 20:58:07 -04:00
commit 864c69fe61
147 changed files with 11233 additions and 0 deletions

View file

@ -0,0 +1,103 @@
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.modules.system.backup;
recipientArgs = concatMapStrings (r: "-r '${lib.strings.trim r}' ") cfg.recipients;
# Convert absolute paths to relative for tar, preserving structure
# e.g., /var/lib/forgejo -> var/lib/forgejo
tarPaths = map (p: removePrefix "/" p) cfg.paths;
excludeArgs = concatMapStrings (e: "--exclude='${e}' ") cfg.exclude;
backupScript = pkgs.writeShellScript "backup" ''
set -euo pipefail
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_NAME="backup-$TIMESTAMP.tar.gz.age"
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
echo "Starting backup: $BACKUP_NAME"
echo "Paths: ${concatStringsSep " " cfg.paths}"
export PATH="${pkgs.gzip}/bin:${pkgs.age-plugin-yubikey}/bin:$PATH"
${pkgs.gnutar}/bin/tar -C / ${excludeArgs}-czf - ${concatStringsSep " " tarPaths} | \
${pkgs.age}/bin/age ${recipientArgs} -o "$TEMP_DIR/$BACKUP_NAME"
${pkgs.rclone}/bin/rclone --config /root/.config/rclone/rclone.conf copy "$TEMP_DIR/$BACKUP_NAME" "${cfg.destination}"
# Prune old backups
${pkgs.rclone}/bin/rclone --config /root/.config/rclone/rclone.conf lsf "${cfg.destination}" | \
sort -r | \
tail -n +$((${toString cfg.keepLast} + 1)) | \
while read -r old; do
${pkgs.rclone}/bin/rclone --config /root/.config/rclone/rclone.conf delete "${cfg.destination}/$old"
done
echo "Backup complete"
'';
in
{
options.modules.system.backup = {
enable = mkEnableOption "Encrypted backups";
paths = mkOption {
type = types.listOf types.str;
default = [];
description = "Absolute paths to include in backup (structure preserved)";
};
exclude = mkOption {
type = types.listOf types.str;
default = [];
description = "Patterns to exclude (passed to tar --exclude)";
};
recipients = mkOption {
type = types.listOf types.str;
default = [];
description = "Age public keys for encryption";
};
destination = mkOption {
type = types.str;
default = "";
description = "Rclone destination";
};
schedule = mkOption {
type = types.str;
default = "daily";
description = "Systemd calendar expression";
};
keepLast = mkOption {
type = types.int;
default = 3;
description = "Number of backups to keep";
};
};
config = mkIf cfg.enable {
environment.systemPackages = [ pkgs.rclone ];
systemd.services.backup = {
description = "Encrypted backup";
serviceConfig = {
Type = "oneshot";
ExecStart = backupScript;
};
};
systemd.timers.backup = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.schedule;
Persistent = true;
};
};
};
}

View file

@ -0,0 +1,20 @@
server=1
rpccookiefile=/var/lib/bitcoin/.cookie
rpccookieperms=group
rpcbind=127.0.0.1
rpcallowip=127.0.0.1
dnsseed=0
onlynet=onion
bind=127.0.0.1
proxy=127.0.0.1:9050
listen=1
listenonion=1
torcontrol=127.0.0.1:9051
txindex=1
dbcache=1024

View file

@ -0,0 +1,80 @@
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.modules.system.bitcoin;
nginx = config.modules.system.nginx;
home = "/var/lib/bitcoin";
bitcoinConf = pkgs.writeTextFile {
name = "bitcoin.conf";
text = builtins.readFile ./config/bitcoin.conf;
};
in
{ options.modules.system.bitcoin = { enable = mkEnableOption "Bitcoin Server"; };
config = mkIf cfg.enable {
modules.system.tor.enable = true;
environment.systemPackages = with pkgs; [
bitcoind
];
users = {
users = {
"btc" = {
inherit home;
description = "Bitcoin Core system user";
isSystemUser = true;
group = "bitcoin";
extraGroups = [ "tor" ];
createHome = true;
};
"nginx" = {
extraGroups = mkIf nginx.enable [
"bitcoin"
];
};
};
groups = {
"bitcoin" = {
members = [
"btc"
config.user.name
];
};
};
};
programs.bash.shellAliases = {
btc = "bitcoin-cli";
};
services.bitcoind = {
"mainnet" = {
enable = true;
user = "btc";
group = "bitcoin";
configFile = bitcoinConf;
dataDir = home;
pidFile = "${home}/bitcoind.pid";
};
};
# Make data dir group-accessible so electrs/clightning can read cookie
systemd.tmpfiles.rules = [
"d ${home} 0750 btc bitcoin -"
];
systemd.services.bitcoind-mainnet = {
wants = [ "tor.service" ];
after = [ "tor.service" ];
serviceConfig.ExecStartPre = "+${pkgs.coreutils}/bin/chmod 750 /var/lib/tor";
};
modules.system.backup.paths = [
"${home}/wallets"
];
};
}

View file

@ -0,0 +1,31 @@
alias=OrdSux
network=bitcoin
bitcoin-datadir=/var/lib/bitcoin
bitcoin-rpcconnect=127.0.0.1
bitcoin-rpcport=8332
lightning-dir=/var/lib/clightning
plugin-dir=/var/lib/clightning/plugins
log-file=/var/lib/clightning/lightningd.log
log-level=info
rpc-file-mode=0660
# Bind RPC locally only
bind-addr=127.0.0.1:9736
# Auto-create Tor hidden service for peer connections
addr=autotor:127.0.0.1:9051
# Route outbound through Tor
proxy=127.0.0.1:9050
always-use-proxy=true
large-channels
fee-base=1000
fee-per-satoshi=10
min-capacity-sat=10000
htlc-minimum-msat=0
funding-confirms=3
max-concurrent-htlcs=30

View file

@ -0,0 +1,115 @@
{ lib, pkgs, config, ... }:
with lib;
let
cfg = config.modules.system.bitcoin.clightning;
btc = config.modules.system.bitcoin;
nginx = config.modules.system.nginx;
home = "/var/lib/clightning";
domain = "ramos.codes";
clnrest = pkgs.callPackage ./plugins/clnrest.nix { };
clnConfig = pkgs.writeTextFile {
name = "lightning.conf";
text = ''
${builtins.readFile ./config/lightning.conf}
bitcoin-cli=${pkgs.bitcoind}/bin/bitcoin-cli
# CLNRest configuration
clnrest-port=3010
clnrest-host=127.0.0.1
clnrest-protocol=https
'';
};
in
{ options.modules.system.bitcoin.clightning = { enable = mkEnableOption "Core Lightning Server"; };
config = mkIf (cfg.enable && btc.enable) {
environment.systemPackages = with pkgs; [
clightning
];
users = {
users = {
"clightning" = {
inherit home;
description = "Core Lightning system user";
isSystemUser = true;
group = "bitcoin";
extraGroups = [ "tor" ];
createHome = true;
};
};
groups = {
"bitcoin" = {
members = mkAfter [
"clightning"
];
};
};
};
programs.bash.shellAliases = {
cln = "lightning-cli";
};
systemd.services.lightningd = {
description = "Core Lightning Daemon";
wantedBy = [ "multi-user.target" ];
wants = [ "bitcoind-mainnet.service" "tor.service" ];
after = [
"bitcoind-mainnet.service"
"tor.service"
"network.target"
];
serviceConfig = {
ExecStartPre = "+${pkgs.coreutils}/bin/chmod 750 /var/lib/bitcoin /var/lib/tor ${home} ${home}/bitcoin";
ExecStart = "${pkgs.clightning}/bin/lightningd --conf=${clnConfig}";
User = "clightning";
Group = "bitcoin";
WorkingDirectory = home;
Type = "simple";
KillMode = "process";
TimeoutSec = 60;
Restart = "always";
RestartSec = 60;
};
};
# Bind mount from /data
fileSystems.${home} = {
device = "/data/clightning";
fsType = "none";
options = [ "bind" ];
};
# Ensure data directory exists with correct permissions
systemd.tmpfiles.rules = mkAfter [
"d /data/clightning 0750 clightning bitcoin -"
"d /data/clightning/bitcoin 0750 clightning bitcoin -"
"d /data/clightning/plugins 0750 clightning bitcoin -"
"L+ /home/${config.user.name}/.lightning - - - - ${home}"
"L+ ${home}/plugins/clnrest - - - - ${clnrest}/libexec/c-lightning/plugins/clnrest"
];
modules.system.backup.paths = [
"${home}/bitcoin/hsm_secret"
"${home}/bitcoin/emergency.recover"
];
services.nginx.virtualHosts."ln.${domain}" = mkIf nginx.enable {
useACMEHost = domain;
forceSSL = true;
locations."/" = {
proxyPass = "https://127.0.0.1:3010";
extraConfig = ''
proxy_ssl_verify off;
'';
};
};
};
}

View file

@ -0,0 +1,54 @@
{
lib,
rustPlatform,
fetchFromGitHub,
pkg-config,
openssl,
protobuf,
}:
rustPlatform.buildRustPackage rec {
pname = "clnrest";
version = "25.02.2";
src = fetchFromGitHub {
owner = "ElementsProject";
repo = "lightning";
rev = "v${version}";
hash = "sha256-SiPYB463l9279+zawsxmql1Ui/dTdah5KgJgmrWsR2A=";
};
cargoLock = {
lockFile = "${src}/Cargo.lock";
};
cargoBuildFlags = [
"-p"
"clnrest"
];
cargoTestFlags = [
"-p"
"clnrest"
];
nativeBuildInputs = [
pkg-config
protobuf
];
buildInputs = [ openssl ];
postInstall = ''
mkdir -p $out/libexec/c-lightning/plugins
mv $out/bin/clnrest $out/libexec/c-lightning/plugins/
rmdir $out/bin
'';
meta = {
description = "Transforms RPC calls into REST APIs";
homepage = "https://docs.corelightning.org/docs/rest";
license = lib.licenses.mit;
platforms = lib.platforms.linux;
mainProgram = "clnrest";
};
}

View file

@ -0,0 +1,13 @@
network = "bitcoin"
electrum_rpc_addr = "127.0.0.1:50001"
cookie_file = "/var/lib/bitcoin/.cookie"
db_dir = "/var/lib/electrs"
log_filters = "INFO"
daemon_rpc_addr = "127.0.0.1:8332"
daemon_p2p_addr = "127.0.0.1:8333"
daemon_dir = "/var/lib/bitcoin"

View file

@ -0,0 +1,121 @@
{ lib, pkgs, config, ... }:
with lib;
let
cfg = config.modules.system.bitcoin.electrum;
nginx = config.modules.system.nginx;
home = "/var/lib/electrs";
btc = config.modules.system.bitcoin;
domain = "ramos.codes";
electrsConfig = pkgs.writeTextFile {
name = "config.toml";
text = builtins.readFile ./config/config.toml;
};
in
{ options.modules.system.bitcoin.electrum = { enable = mkEnableOption "Electrs Server"; };
config = mkIf (cfg.enable && btc.enable) {
#TODO: Fix the failing overlay due to `cargoHash/cargoSha256`
#nixpkgs.overlays = [
# (final: prev: {
# electrs = prev.electrs.overrideAttrs (old: rec {
# pname = "electrs";
# version = "0.10.8";
# src = pkgs.fetchFromGitHub {
# owner = "romanz";
# repo = pname;
# rev = "v${version}";
# hash = "sha256-L26jzAn8vwnw9kFd6ciyYS/OLEFTbN8doNKy3P8qKRE=";
# };
# #cargoDeps = old.cargoDeps.overrideAttrs (const {
# # name = "electrs-${version}.tar.gz";
# # inherit src;
# # sha256 = "";
# #});
# cargoHash = "sha256-lBRcq73ri0HR3duo6Z8PdSjnC8okqmG5yWeHxH/LmcU=";
# });
# })
#];
environment.systemPackages = with pkgs; [
electrs
];
users = {
users = {
"electrs" = {
inherit home;
description = "Electrs system user";
isSystemUser = true;
group = "bitcoin";
createHome = true;
};
};
groups = {
"bitcoin" = {
members = mkAfter [
"electrs"
];
};
};
};
systemd.services.electrs = {
description = "Electrs Bitcoin Indexer";
wantedBy = [ "multi-user.target" ];
wants = [ "bitcoind-mainnet.service" ];
after = [
"bitcoind-mainnet.service"
"network.target"
];
serviceConfig = {
ExecStartPre = "+${pkgs.coreutils}/bin/chmod 750 /var/lib/bitcoin";
ExecStart = "${pkgs.electrs}/bin/electrs --conf=${electrsConfig}";
User = "electrs";
Group = "bitcoin";
WorkingDirectory = home;
Type = "simple";
KillMode = "process";
TimeoutSec = 60;
Restart = "always";
RestartSec = 60;
};
};
# Bind mount from /data
fileSystems.${home} = {
device = "/data/electrs";
fsType = "none";
options = [ "bind" ];
};
# Ensure db directory exists with correct permissions
systemd.tmpfiles.rules = [
"d /data/electrs 0750 electrs bitcoin -"
];
# Nginx SSL proxy for Electrum protocol (TCP)
networking.firewall.allowedTCPPorts = mkIf nginx.enable [ 50002 ];
services.nginx.streamConfig = mkIf nginx.enable ''
map $ssl_server_name $electrs_backend {
electrum.${domain} 127.0.0.1:50001;
default "";
}
server {
listen 50002 ssl;
proxy_pass $electrs_backend;
ssl_certificate /var/lib/acme/${domain}/fullchain.pem;
ssl_certificate_key /var/lib/acme/${domain}/key.pem;
}
'';
};
}

View file

@ -0,0 +1,35 @@
let
mkModules = dir: isRoot:
let
entries = builtins.readDir dir;
names = builtins.attrNames entries;
isModuleDir = path:
builtins.pathExists path &&
builtins.readFileType path == "directory" &&
builtins.baseNameOf path != "config" &&
builtins.baseNameOf path != "plugins" &&
builtins.baseNameOf path != "home-manager" &&
builtins.baseNameOf path != "disko";
isModule = file: file == "default.nix";
isNix = file: builtins.match ".*\\.nix" file != null && file != "default.nix";
in
builtins.concatMap (name:
let
path = "${dir}/${name}";
in
if isModuleDir path then
mkModules path false
else if isModule name && !isRoot then
[dir]
else if isNix name then
[path]
else
[]
) names;
in
{
imports = mkModules ./. true;
}

View file

@ -0,0 +1,75 @@
{ lib, ... }:
{
disko.devices = {
disk = {
main = {
type = "disk";
device = "/dev/sda";
content = {
type = "gpt";
partitions = {
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
lvm = {
size = "100%";
content = {
type = "lvm_pv";
vg = "vg0";
};
};
};
};
};
};
lvm_vg = {
vg0 = {
type = "lvm_vg";
lvs = {
root = {
size = "200G";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
data = {
size = "1T";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/data";
};
};
bitcoin = {
size = "1T";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/var/lib/bitcoin";
};
};
frigate = {
size = "3T";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/var/lib/frigate";
};
};
# ~300GB left unallocated for future growth
};
};
};
};
}

View file

@ -0,0 +1,106 @@
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.modules.system.forgejo;
nginx = config.modules.system.nginx;
domain = "ramos.codes";
socketPath = "/run/forgejo/forgejo.sock";
in
{
options.modules.system.forgejo = {
enable = mkEnableOption "Forgejo Server";
};
config = mkIf cfg.enable {
users.groups.git = {};
users.users.git = {
isSystemUser = true;
group = "git";
home = "/var/lib/forgejo";
shell = "${pkgs.bash}/bin/bash";
};
users.users.nginx = mkIf nginx.enable {
extraGroups = [ "git" ];
};
# Bind mount from /data
fileSystems."/var/lib/forgejo" = {
device = "/data/forgejo";
fsType = "none";
options = [ "bind" ];
};
systemd.tmpfiles.rules = [
"d /data/forgejo 0750 git git -"
"d /data/forgejo/.ssh 0700 git git -"
"d /data/forgejo/custom 0750 git git -"
"d /data/forgejo/data 0750 git git -"
];
services.forgejo = {
enable = true;
user = "git";
group = "git";
stateDir = "/var/lib/forgejo";
settings = {
DEFAULT = {
APP_NAME = "Git Server";
APP_SLOGAN = "";
};
service.REQUIRE_SIGNIN_VIEW = false;
server = {
DOMAIN = "git.${domain}";
ROOT_URL = "https://git.${domain}/";
PROTOCOL = "http+unix";
HTTP_ADDR = socketPath;
SSH_DOMAIN = "git.${domain}";
SSH_PORT = 22;
START_SSH_SERVER = false;
LANDING_PAGE = "explore";
LFS_MAX_FILE_SIZE = 0;
};
"repository.upload" = {
FILE_MAX_SIZE = 0;
};
service = {
REGISTER_MANUAL_CONFIRM = true;
DISABLE_REGISTRATION = false;
DEFAULT_ALLOW_CREATE_ORGANIZATION = false;
};
admin = {
DISABLE_REGULAR_ORG_CREATION = true;
};
auth = {
ENABLE_BASIC_AUTHENTICATION = true;
};
};
database = {
type = "sqlite3";
path = "/var/lib/forgejo/data/forgejo.db";
};
};
modules.system.backup.paths = [
"/var/lib/forgejo"
];
services.nginx.virtualHosts."git.${domain}" = mkIf nginx.enable {
useACMEHost = domain;
forceSSL = true;
extraConfig = "client_max_body_size 0;";
locations."/" = {
proxyPass = "http://unix:${socketPath}";
};
};
};
}

View file

@ -0,0 +1,162 @@
# Frigate Camera Setup
## Camera Models
| Camera | Model | MAC | IP |
|--------|-------|-----|-----|
| cam4 | W461ASC | 00:1f:54:c2:d1:b1 | 192.168.1.194 |
| cam1 | B463AJ | 00:1f:54:a9:81:d1 | 192.168.1.167 |
| cam2 | W463AQ (ch1) | 00:1f:54:b2:9b:1d | 192.168.1.147 |
| cam3 | W463AQ (ch2) | 00:1f:54:b2:9b:1d | 192.168.1.147 |
| cam5 | SL300 | | | |
## Network Architecture
- Camera network: 192.168.1.0/24 (isolated, no internet)
- Server NIC: enp2s0f1 @ 192.168.1.1
- WiFi AP: TP-Link RE315 @ 192.168.1.254
- DHCP range: 192.168.1.100-200
## RTSP URL Format
```
rtsp://admin:ocu?u3Su@<IP>/cam/realmonitor?channel=<CH>&subtype=0
```
- channel=1 for single-camera devices
- channel=1,2 for dual-camera devices (W463AQ)
- subtype=0 for main stream, subtype=1 for sub stream
## Camera Reset Procedures
### W461ASC (cam4)
1. Keep camera powered on
2. Reset button is on the back of the camera
3. Press and hold reset button for 30-60 seconds until chime sounds
### B463AJ (cam1)
1. Remove doorbell from mount
2. Locate reset button on the back
3. Press and hold until you hear chime reset sound
4. Reconnect via Lorex app as new device
### W463AQ (cam2/cam3)
1. Keep camera powered on
2. Rotate the lens upwards to reveal hidden reset button
3. Press and hold reset button until you hear audio prompt
4. Flashing green Smart Security Lighting confirms reset
5. Solid green = not fully reset, repeat if needed
### SL300 (cam5)
1. Keep camera powered on
2. Tilt camera lens upwards to reveal reset/microSD card cover
3. Remove the cover
4. Press and hold reset button until audio prompt
5. Replace cover quickly
6. Wait for green LED flash + audio confirmation
## Initial Setup
1. Temporarily enable internet for camera network:
```bash
sudo iptables -t nat -A POSTROUTING -s 192.168.1.0/24 -o enp2s0f0 -j MASQUERADE
sudo sysctl -w net.ipv4.ip_forward=1
```
2. Connect camera to "cams" WiFi network
3. Use Lorex app to configure camera (requires cloud - CCP middleman)
4. Get camera MAC from DHCP leases:
```bash
cat /var/lib/dnsmasq/dnsmasq.leases
```
5. Add DHCP reservation in `system.nix`:
```nix
dhcp-host = [
"aa:bb:cc:dd:ee:ff,192.168.1.XXX,camera_name"
];
```
6. Add MAC to firewall block list in `system.nix`:
```nix
iptables -A FORWARD -m mac --mac-source aa:bb:cc:dd:ee:ff -j DROP
```
7. Update camera IP in `frigate/default.nix` and enable
8. Deploy and disable internet:
```bash
nixos-rebuild switch --flake .#server --target-host server
sudo iptables -t nat -D POSTROUTING -s 192.168.1.0/24 -o enp2s0f0 -j MASQUERADE
sudo sysctl -w net.ipv4.ip_forward=0
```
## Storage
| Path | Bind Mount | Contents |
|------|------------|----------|
| /var/lib/frigate | /data/frigate/lib | Database, recordings, clips |
## Notes
- Lorex cameras are cloud-only for configuration (no local web UI responds)
- RTSP works locally without internet
- Cameras phone home aggressively when internet is available - keep isolated
- Haswell CPU cannot hardware decode HEVC - using CPU decode
- Consider T400 GPU for hardware acceleration if scaling to more cameras
## Port Scan Results (W461ASC)
- 80/tcp - HTTP (non-responsive, proprietary)
- 554/tcp - RTSP (working)
- 8086/tcp - Proprietary
- 35000/tcp - Proprietary
## Planned Upgrades
Replace Lorex cameras with proper RTSP/ONVIF cameras for reliable Frigate integration.
| Current | Replacement | Price | Notes |
|---------|-------------|-------|-------|
| cam1 (B861AJ) | Reolink Video Doorbell WiFi | ~$120 | 5MP, wired power + WiFi, always-on |
| cam4 (W461ASC) | TP-Link Tapo C110 | ~$30 | 3MP, compact, window-friendly |
| cam2 + cam3 (W463AQ) | Reolink E1 Pro | ~$45 | 4MP, 355° pan |
| cam5 (SL300) | **Remove** | - | Obstructed, overlaps with cam4 |
**Total: ~$195**
### Reolink Video Doorbell WiFi
- URL: https://reolink.com/us/product/reolink-video-doorbell-wifi
- Model: SKU 2267808
- Resolution: 5MP (2560x1920 @ 20fps)
- Dimensions: Standard doorbell form factor
- Power: Hardwired 12-24VAC or DC 24V (always-on, no battery)
- Network: 2.4GHz/5GHz WiFi
- Protocols: RTSP, ONVIF, RTMP, HTTPS
- FOV: 180° diagonal (135° H, 100° V)
### TP-Link Tapo C110
- URL: https://www.tp-link.com/us/home-networking/cloud-camera/tapo-c110/
- Resolution: 3MP (2304x1296 @ 15fps)
- Dimensions: 2.66" x 2.15" x 3.89" (compact cube, similar to Lorex W461ASC)
- Power: 9V DC adapter
- Network: 2.4GHz WiFi
- Protocols: RTSP, ONVIF (officially supported NVR mode)
- RTSP URL: `rtsp://user:pass@IP:554/stream1` (main), `stream2` (sub)
- Frigate: Confirmed working - https://www.simonam.dev/tapo-c110-frigate-config/
### Reolink E1 Pro
- URL: https://reolink.com/us/product/e1-pro/
- Resolution: 4MP (2560x1440)
- Dimensions: ~4" dome with pan/tilt
- Power: 5V DC adapter
- Network: 2.4GHz/5GHz WiFi
- Protocols: RTSP, ONVIF
- Features: Pan 355°, Tilt 50°, person/pet detection
**Why replace Lorex:** Cloud-dependent config, no ONVIF, doorbell sleeps on battery, aggressive phone-home behavior requires network isolation.

View file

@ -0,0 +1,295 @@
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.modules.system.frigate;
nginx = config.modules.system.nginx;
domain = "ramos.codes";
user = config.sops.placeholder."RTSP_USER";
pass = config.sops.placeholder."RTSP_PASS";
privateAccessRules = concatMapStringsSep "\n" (cidr: "allow ${cidr};") nginx.privateAllowCidrs + "\ndeny all;";
in
{
options.modules.system.frigate = {
enable = mkEnableOption "Enable Frigate NVR";
};
config = mkIf cfg.enable {
# Allow user to access frigate recordings via SSHFS
users.users.${config.user.name}.extraGroups = [ "frigate" ];
# go2rtc config with credentials from sops
sops.templates."go2rtc.yaml" = {
content = ''
rtsp:
listen: ":8554"
webrtc:
listen: ":8555"
streams:
cam1: "rtsp://${user}:${pass}@192.168.1.167/cam/realmonitor?channel=1&subtype=0#backchannel=1"
cam1_sub: "rtsp://${user}:${pass}@192.168.1.167/cam/realmonitor?channel=1&subtype=1"
cam2: "rtsp://${user}:${pass}@192.168.1.147/cam/realmonitor?channel=1&subtype=0#backchannel=1"
cam2_sub: "rtsp://${user}:${pass}@192.168.1.147/cam/realmonitor?channel=1&subtype=1"
cam3: "rtsp://${user}:${pass}@192.168.1.147/cam/realmonitor?channel=2&subtype=0#backchannel=1"
cam3_sub: "rtsp://${user}:${pass}@192.168.1.147/cam/realmonitor?channel=2&subtype=1"
cam4: "rtsp://${user}:${pass}@192.168.1.194/cam/realmonitor?channel=1&subtype=0"
cam4_sub: "rtsp://${user}:${pass}@192.168.1.194/cam/realmonitor?channel=1&subtype=1"
'';
mode = "0444"; # go2rtc runs as dynamic user, needs read access
};
# go2rtc service using sops-templated config
services.go2rtc.enable = true;
systemd.services.go2rtc = {
serviceConfig.ExecStart = mkForce "${pkgs.go2rtc}/bin/go2rtc -config ${config.sops.templates."go2rtc.yaml".path}";
after = [ "sops-nix.service" ];
wants = [ "sops-nix.service" ];
};
services.frigate = {
enable = true;
package = pkgs.unstable.frigate;
hostname = "frigate.${domain}";
vaapiDriver = "i965"; # Haswell iGPU for H.264 decode
settings = {
mqtt.enabled = false;
ffmpeg = {
hwaccel_args = "preset-vaapi"; # VAAPI for H.264 substream detection
input_args = "preset-rtsp-restream"; # TCP transport for go2rtc
};
birdseye = {
mode = "continuous";
width = 1280;
height = 720;
quality = 8; # 8 - 31
};
motion = {
enabled = true;
};
detect = {
enabled = true;
min_initialized = 3;
max_disappeared = 25;
width = 1280;
height = 720;
};
audio = {
enabled = true;
max_not_heard = 30;
min_volume = 600;
listen = [
"glass"
"shatter"
"fire_alarm"
"boom"
"thump"
"siren"
"alarm"
"explosion"
"burst"
];
};
audio_transcription.enabled = false;
record = {
enabled = true;
continuous.days = 3; # Full 24/7 footage
motion.days = 7; # Motion segments after continuous expires
detections.retain = {
days = 14; # Any tracked object (person, car, etc.)
mode = "motion";
};
alerts.retain = {
days = 30; # Zone violations, loitering - important stuff
mode = "all";
};
};
snapshots = {
enabled = true;
retain = {
default = 3;
};
quality = 80;
};
cameras = {
cam1 = {
enabled = true;
ffmpeg.inputs = [
{
path = "rtsp://127.0.0.1:8554/cam1";
roles = [ "record" ];
}
{
path = "rtsp://127.0.0.1:8554/cam1_sub";
roles = [ "detect" "audio" ];
}
];
};
cam2 = {
enabled = true;
motion.enabled = false;
detect.enabled = false;
objects.mask = [ "0.969,0.078,0.846,0.075,0.845,0.034,0.97,0.037" ];
ffmpeg.inputs = [
{
path = "rtsp://127.0.0.1:8554/cam2";
roles = [ "record" ];
}
{
path = "rtsp://127.0.0.1:8554/cam2_sub";
roles = [ "detect" "audio" ];
}
];
};
cam3 = {
enabled = true;
motion.enabled = false;
detect.enabled = false;
ffmpeg.inputs = [
{
path = "rtsp://127.0.0.1:8554/cam3";
roles = [ "record" ];
}
{
path = "rtsp://127.0.0.1:8554/cam3_sub";
roles = [ "detect" "audio" ];
}
];
};
cam4 = {
enabled = true;
audio.enabled = false;
motion.mask = [ "0.811,0.109,0.954,0.111,0.959,0.065,0.811,0.055" ];
zones.zone1 = {
friendly_name = "lot";
coordinates = "0.299,0.438,0.191,0.951,0.453,0.964,0.453,0.437";
loitering_time = 10;
};
ffmpeg.inputs = [
{
path = "rtsp://127.0.0.1:8554/cam4";
roles = [ "record" ];
}
{
path = "rtsp://127.0.0.1:8554/cam4_sub";
roles = [ "detect" ];
}
];
};
};
classification = {
custom = {
"door" = {
enabled = true;
name = "door";
threshold = 0.8;
state_config = {
cameras = {
cam2.crop = [
0.8595647692717828
0.39901413156128707
0.9903488513256276
0.6315191663236775
];
cam3.crop = [
0.0008617338314475493
0.3909394833748086
0.12040036569190293
0.6034526066822848
];
};
motion = true;
};
};
"lot" = {
enabled = true;
name = "lot";
threshold = 0.8;
state_config = {
cameras = {
cam4.crop = [
0.2757899560295573
0.5156825410706086
0.4445399560295573
0.8156825410706086
];
};
motion = true;
};
};
};
};
};
};
# Add SSL to frigate's nginx virtualHost
services.nginx.virtualHosts."frigate.${domain}" = mkIf nginx.enable {
useACMEHost = domain;
forceSSL = true;
locations."/" = {
extraConfig = privateAccessRules;
};
locations."/go2rtc/" = {
proxyPass = "http://127.0.0.1:1984/";
proxyWebsockets = true;
extraConfig = privateAccessRules;
};
};
# Frigate segment cache in RAM (reduces disk writes)
fileSystems."/var/cache/frigate" = {
device = "tmpfs";
fsType = "tmpfs";
options = [ "size=512M" "mode=0755" ];
};
systemd.tmpfiles.rules = [
# Set ownership after tmpfs mount
"d /var/cache/frigate 0750 frigate frigate -"
# Create log directories for Frigate API (NixOS uses journald, but API expects these)
"d /dev/shm/logs 0755 frigate frigate -"
"d /dev/shm/logs/frigate 0755 frigate frigate -"
"d /dev/shm/logs/nginx 0755 frigate frigate -"
"d /dev/shm/logs/go2rtc 0755 frigate frigate -"
];
# Pipe journald logs to files for Frigate GUI
systemd.services.frigate-log-pipe = {
description = "Pipe logs to /dev/shm for Frigate GUI";
wantedBy = [ "multi-user.target" ];
after = [ "frigate.service" "go2rtc.service" "nginx.service" ];
serviceConfig = {
Type = "simple";
Restart = "always";
ExecStart = pkgs.writeShellScript "frigate-log-pipe" ''
while true; do
${pkgs.systemd}/bin/journalctl -u frigate -n 500 -o cat > /dev/shm/logs/frigate/current 2>/dev/null
${pkgs.systemd}/bin/journalctl -u go2rtc -n 500 -o cat > /dev/shm/logs/go2rtc/current 2>/dev/null
${pkgs.systemd}/bin/journalctl -u nginx -n 500 -o cat > /dev/shm/logs/nginx/current 2>/dev/null
chown frigate:frigate /dev/shm/logs/*/current
sleep 5
done
'';
};
};
# Backup recordings/database
modules.system.backup = {
paths = [ "/var/lib/frigate" ];
};
};
}

View file

@ -0,0 +1,23 @@
{ config, ... }:
{
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.users.${config.user.name} = {
imports = [
../../../../../user
../../../../../user/home.nix
../../../../../user/modules
];
home.stateVersion = "25.11";
# Machine-specific modules
modules.user = {
neovim.enable = false;
vim.enable = true;
tmux.enable = false;
utils.dev.enable = true;
};
};
}

View file

@ -0,0 +1,59 @@
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.modules.system.immich;
nginx = config.modules.system.nginx;
domain = "ramos.codes";
port = 2283;
privateAccessRules = concatMapStringsSep "\n" (cidr: "allow ${cidr};") nginx.privateAllowCidrs + "\ndeny all;";
in
{
options.modules.system.immich = {
enable = mkEnableOption "Immich Photo Server";
};
config = mkIf cfg.enable {
# Bind mount from /data
systemd.tmpfiles.rules = [
"d /data/immich 0750 immich immich -"
"d /data/postgresql 0750 postgres postgres -"
];
fileSystems."/var/lib/immich" = {
device = "/data/immich";
fsType = "none";
options = [ "bind" ];
};
fileSystems."/var/lib/postgresql" = {
device = "/data/postgresql";
fsType = "none";
options = [ "bind" ];
};
services.immich = {
enable = true;
port = port;
host = "127.0.0.1";
mediaLocation = "/var/lib/immich";
machine-learning.enable = false;
};
modules.system.backup.paths = [
"/var/lib/immich"
"/var/lib/postgresql"
];
services.nginx.virtualHosts."photos.${domain}" = mkIf nginx.enable {
useACMEHost = domain;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString port}";
proxyWebsockets = true;
extraConfig = privateAccessRules;
};
};
};
}

View file

@ -0,0 +1,163 @@
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.modules.system.nginx;
domain = "ramos.codes";
privateAccessRules = concatMapStringsSep "\n" (cidr: "allow ${cidr};") cfg.privateAllowCidrs + "\ndeny all;";
in
{
options.modules.system.nginx = {
enable = mkEnableOption "Nginx Reverse Proxy";
privateAllowCidrs = mkOption {
type = types.listOf types.str;
default = [
"192.168.0.0/24"
"10.8.0.0/24"
];
description = ''
CIDR ranges allowed to access private vhosts (LAN + WireGuard).
'';
};
};
config = mkIf cfg.enable {
networking.firewall.allowedTCPPorts = [ 80 443 ];
systemd.services.nginx.serviceConfig.LimitNOFILE = 65536;
environment.etc."fail2ban/filter.d/nginx-404.conf".text = ''
[Definition]
failregex = ^<HOST> - .+ "(GET|POST|HEAD|PUT|DELETE|PATCH) .+ HTTP/[0-9.]+" 404
ignoreregex =
'';
environment.etc."fail2ban/filter.d/nginx-401.conf".text = ''
[Definition]
failregex = ^<HOST> - .+ "(GET|POST|HEAD|PUT|DELETE|PATCH) .+ HTTP/[0-9.]+" 401
ignoreregex =
'';
services.fail2ban.jails.nginx-404 = ''
enabled = true
filter = nginx-404
logpath = /var/log/nginx/access.log
maxretry = 10
findtime = 10m
bantime = 24h
'';
services.fail2ban.jails.nginx-401 = ''
enabled = true
filter = nginx-401
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 10m
bantime = 24h
'';
security.acme = {
acceptTerms = true;
defaults.email = config.user.email;
certs."${domain}" = {
domain = "*.${domain}";
dnsProvider = "namecheap";
environmentFile = "/var/lib/acme/namecheap.env";
group = "nginx";
};
};
services.nginx = {
enable = true;
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
eventsConfig = "worker_connections 4096;";
# Catch-all default - friendly error for unknown subdomains
virtualHosts."_" = {
default = true;
useACMEHost = domain;
forceSSL = true;
locations."/" = {
return = "404 'Not Found: This subdomain does not exist.'";
extraConfig = ''
add_header Content-Type text/plain;
'';
};
};
virtualHosts."wg.${domain}" = {
useACMEHost = domain;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString config.modules.system.wstunnel.listenPort}";
proxyWebsockets = true;
extraConfig = ''
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
'';
};
};
virtualHosts."ai.${domain}" = let
apiKeyAuth = ''
set $api_key "";
if ($http_authorization ~* "^Bearer (.+)$") {
set $api_key $1;
}
if ($api_key = "") {
return 401 '{"error": "Missing Authorization header"}';
}
include ${config.sops.templates."nginx-ai-auth.conf".path};
'';
in {
useACMEHost = domain;
forceSSL = true;
# Web UI + llama.cpp API (browser, /v1/* calls from the UI)
# Auth handled by llama.cpp itself (--api-key flag)
locations."/" = {
proxyPass = "http://192.168.0.23:8000";
proxyWebsockets = true;
};
# Llama Stack API (opencode, programmatic clients)
# Clients use baseURL: https://ai.ramos.codes/stack/v1
locations."/stack/v1/" = {
proxyPass = "http://192.168.0.23:8321/v1/";
proxyWebsockets = true;
extraConfig = apiKeyAuth + ''
proxy_read_timeout 300s;
proxy_send_timeout 300s;
'';
};
# MCP servers (namespaced, for llama.cpp web UI + direct access)
locations."/mcp/web_search/" = {
proxyPass = "http://192.168.0.23:8002/";
proxyWebsockets = true;
extraConfig = ''
include ${config.sops.templates."nginx-mcp-auth.conf".path};
proxy_read_timeout 300s;
proxy_send_timeout 300s;
'';
};
};
virtualHosts."comfy.${domain}" = {
useACMEHost = domain;
forceSSL = true;
locations."/" = {
proxyPass = "http://192.168.0.23:8188";
proxyWebsockets = true;
extraConfig = privateAccessRules;
};
};
};
};
}

View file

@ -0,0 +1,136 @@
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.modules.system.sandpack;
domain = "ramos.codes";
privateAccessRules = concatMapStringsSep "\n" (cidr: "allow ${cidr};") config.modules.system.nginx.privateAllowCidrs + "\ndeny all;";
staticBrowserServer = pkgs.stdenvNoCC.mkDerivation (finalAttrs: let
pnpm = pkgs.pnpm_10;
in {
pname = "static-browser-server";
version = "1.0.6";
src = pkgs.fetchFromGitHub {
owner = "LibreChat-AI";
repo = "static-browser-server";
rev = "30de7ae4ebf5433acc0fb640649fb77426a79e04";
hash = "sha256-OVAGnoh7KRmTPY2bXE0kvCMiPx3tXAooDa8n8ujugYM=";
};
patches = [ ./pnpm-lock.patch ];
pnpmDeps = pkgs.fetchPnpmDeps {
inherit (finalAttrs) pname version src patches;
pnpm = pnpm;
fetcherVersion = 3;
hash = "sha256-+Gz8tQy4rkoi365To9GI6sShPTjuKEmZxtV5mEB2UYk=";
};
nativeBuildInputs = [
pkgs.makeWrapper
pkgs.nodejs
pkgs.pnpmConfigHook
pnpm
];
buildPhase = ''
runHook preBuild
pnpm build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/libexec/static-browser-server $out/bin
cp -r out $out/libexec/
pnpm exec esbuild \
./servers/demo-server.ts \
--bundle \
--platform=node \
--format=cjs \
--outfile=$out/libexec/static-browser-server/demo-server.js
makeWrapper ${pkgs.nodejs}/bin/node $out/bin/static-browser-server \
--add-flags $out/libexec/static-browser-server/demo-server.js
runHook postInstall
'';
});
in
{
options.modules.system.sandpack = {
enable = mkEnableOption "Sandpack services";
};
config = mkIf cfg.enable {
virtualisation.oci-containers = {
backend = "podman";
containers.sandpack-bundler = {
image = "ghcr.io/librechat-ai/codesandbox-client/bundler:latest";
ports = [ "127.0.0.1:4333:80" ];
};
};
systemd.services.sandpack-preview = {
description = "Sandpack static preview server";
after = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${staticBrowserServer}/bin/static-browser-server";
WorkingDirectory = "${staticBrowserServer}/libexec/static-browser-server";
Restart = "always";
RestartSec = 5;
DynamicUser = true;
Environment = [
"HOST=127.0.0.1"
"PORT=4324"
];
};
};
services.nginx.virtualHosts."bundler.${domain}" = {
useACMEHost = domain;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:4333";
extraConfig = ''
${privateAccessRules}
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age "3600" always;
if ($request_method = OPTIONS) {
return 204;
}
'';
};
};
services.nginx.virtualHosts."preview.${domain}" = {
useACMEHost = domain;
forceSSL = true;
serverAliases = [ "~^.+-preview\\.ramos\\.codes$" ];
locations."/" = {
proxyPass = "http://127.0.0.1:4324";
extraConfig = ''
${privateAccessRules}
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age "3600" always;
if ($request_method = OPTIONS) {
return 204;
}
'';
};
};
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.modules.system.tor;
in
{
options.modules.system.tor = {
enable = mkEnableOption "Tor";
};
config = mkIf cfg.enable {
services.tor = {
enable = true;
client = {
enable = true;
# SOCKS proxy on 127.0.0.1:9050
};
settings = {
ControlPort = 9051;
CookieAuthentication = true;
CookieAuthFileGroupReadable = true;
DataDirectoryGroupReadable = true;
};
};
};
}

View file

@ -0,0 +1,72 @@
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.modules.system.webdav;
domain = "ramos.codes";
privateAccessRules = concatMapStringsSep "\n" (cidr: "allow ${cidr};") config.modules.system.nginx.privateAllowCidrs + "\ndeny all;";
in
{
options.modules.system.webdav = {
enable = mkEnableOption "WebDAV server for phone backups";
directory = mkOption {
type = types.path;
default = "/var/lib/seedvault";
description = "Directory to store backups";
};
};
config = mkIf cfg.enable {
# Create backup directory
systemd.tmpfiles.rules = [
"d ${cfg.directory} 0750 webdav webdav -"
];
services.webdav = {
enable = true;
# Credentials in /var/lib/webdav/env:
# WEBDAV_USERNAME=seedvault
# WEBDAV_PASSWORD=your-secure-password
environmentFile = "/var/lib/webdav/env";
settings = {
address = "127.0.0.1";
port = 8090;
directory = cfg.directory;
behindProxy = true;
permissions = "CRUD"; # Create, Read, Update, Delete
users = [
{
username = "{env}WEBDAV_USERNAME";
password = "{env}WEBDAV_PASSWORD";
}
];
};
};
services.nginx.virtualHosts."backup.${domain}" = {
useACMEHost = domain;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:8090";
extraConfig = ''
${privateAccessRules}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebDAV needs these
proxy_pass_request_headers on;
proxy_set_header Destination $http_destination;
# Large file uploads for backups
client_max_body_size 0;
proxy_request_buffering off;
'';
};
};
};
}

View file

@ -0,0 +1,107 @@
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.modules.system.wireguard;
in
{
options.modules.system.wireguard = {
enable = mkEnableOption "WireGuard VPN";
address = mkOption {
type = types.str;
default = "10.8.0.1/24";
description = "WireGuard interface address with CIDR";
};
subnet = mkOption {
type = types.str;
default = "10.8.0.0/24";
description = "WireGuard subnet used for peer allocations";
};
listenPort = mkOption {
type = types.port;
default = 51820;
description = "WireGuard UDP listen port";
};
privateKeyFile = mkOption {
type = types.str;
default = "/var/lib/wireguard/server.key";
description = "Path to WireGuard server private key";
};
peers = mkOption {
type = types.listOf (types.submodule ({ ... }: {
options = {
publicKey = mkOption {
type = types.str;
description = "Peer public key";
};
allowedIPs = mkOption {
type = types.listOf types.str;
description = "Allowed IPs for peer, usually a single /32";
};
presharedKeyFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Optional preshared key file";
};
persistentKeepalive = mkOption {
type = types.nullOr types.int;
default = 25;
description = "Persistent keepalive interval seconds";
};
};
}));
default = [ ];
description = "WireGuard peers";
};
};
config = mkIf cfg.enable {
networking.firewall.allowedUDPPorts = [ cfg.listenPort ];
networking.firewall.trustedInterfaces = [ "wg0" ];
networking.nat.internalInterfaces = mkAfter [ "wg0" ];
systemd.tmpfiles.rules = [
"d /var/lib/wireguard 0700 root root -"
];
systemd.services.wireguard-generate-key = {
description = "Generate WireGuard server key if missing";
before = [ "wireguard-wg0.service" ];
wantedBy = [ "wireguard-wg0.service" ];
serviceConfig = {
Type = "oneshot";
};
path = with pkgs; [ wireguard-tools coreutils ];
script = ''
set -euo pipefail
if [ ! -s "${cfg.privateKeyFile}" ]; then
umask 077
wg genkey | tee "${cfg.privateKeyFile}" | wg pubkey > /var/lib/wireguard/server.pub
elif [ ! -s /var/lib/wireguard/server.pub ]; then
umask 077
wg pubkey < "${cfg.privateKeyFile}" > /var/lib/wireguard/server.pub
fi
'';
};
networking.wireguard.interfaces.wg0 = {
ips = [ cfg.address ];
listenPort = cfg.listenPort;
privateKeyFile = cfg.privateKeyFile;
peers = map (peer: {
inherit (peer) publicKey allowedIPs;
presharedKeyFile = peer.presharedKeyFile;
persistentKeepalive = peer.persistentKeepalive;
}) cfg.peers;
};
};
}

View file

@ -0,0 +1,37 @@
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.modules.system.wstunnel;
in
{
options.modules.system.wstunnel = {
enable = mkEnableOption "wstunnel WebSocket transport for WireGuard";
listenPort = mkOption {
type = types.port;
default = 8080;
description = "Local port wstunnel server listens on (nginx proxies to this)";
};
wireguardPort = mkOption {
type = types.port;
default = 51820;
description = "Local WireGuard port to forward traffic to";
};
};
config = mkIf cfg.enable {
systemd.services.wstunnel = {
description = "wstunnel WebSocket server for WireGuard transport";
after = [ "network.target" "wireguard-wg0.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${pkgs.wstunnel}/bin/wstunnel server ws://127.0.0.1:${toString cfg.listenPort} --restrict-to 127.0.0.1:${toString cfg.wireguardPort}";
Restart = "on-failure";
RestartSec = 5;
DynamicUser = true;
};
};
};
}