feat(portal): move edge proxy services from Jeeves
- move HAProxy and ACME certificate management to Portal - route application backends to Jeeves over Tailscale - restrict Jeeves backend ports to the Tailscale interface - expose Gems for remote proxy access
This commit is contained in:
@@ -17,6 +17,15 @@
|
||||
allowedTCPPorts = [ ];
|
||||
allowedUDPPorts = [ ];
|
||||
};
|
||||
interfaces.tailscale0.allowedTCPPorts = [
|
||||
5000 # Nix binary cache
|
||||
6443 # Gitea HTTP
|
||||
8000 # Audiobookshelf
|
||||
8001 # Norn Sight
|
||||
8002 # Gems
|
||||
8091 # Share
|
||||
8096 # Jellyfin HTTP
|
||||
];
|
||||
allowedTCPPorts = [
|
||||
8070
|
||||
];
|
||||
|
||||
@@ -36,7 +36,7 @@ in
|
||||
Type = "simple";
|
||||
User = "gems";
|
||||
Group = "gems";
|
||||
ExecStart = "${pkgs.my_python}/bin/python -m python.gems.main --host 127.0.0.1 --port 8002";
|
||||
ExecStart = "${pkgs.my_python}/bin/python -m python.gems.main --host 0.0.0.0 --port 8002";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
StandardOutput = "journal";
|
||||
|
||||
@@ -3,7 +3,6 @@ let
|
||||
in
|
||||
{
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
6443
|
||||
2223
|
||||
];
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ in
|
||||
{
|
||||
services.jellyfin = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
openFirewall = false;
|
||||
dataDir = "${vars.services}/jellyfin";
|
||||
cacheDir = "${vars.services}/jellyfin/cache";
|
||||
};
|
||||
|
||||
@@ -7,6 +7,6 @@ in
|
||||
package = pkgs.nix-serve-ng;
|
||||
enable = true;
|
||||
secretKeyFile = "${vars.secrets}/services/nix-cache/cache-priv-key.pem";
|
||||
openFirewall = true;
|
||||
openFirewall = false;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
let
|
||||
domains = [
|
||||
"audiobookshelf"
|
||||
"cache"
|
||||
"gitea"
|
||||
"gems"
|
||||
"jellyfin"
|
||||
"share"
|
||||
];
|
||||
extraDomains = [ "www.norn-sight.com" ];
|
||||
|
||||
makeCert = name: {
|
||||
name = "${name}.tmmworkshop.com";
|
||||
value = {
|
||||
webroot = "/var/lib/acme/.challenges";
|
||||
group = "acme";
|
||||
reloadServices = [ "haproxy.service" ];
|
||||
};
|
||||
};
|
||||
|
||||
makeExtraCert = name: {
|
||||
inherit name;
|
||||
value = {
|
||||
webroot = "/var/lib/acme/.challenges";
|
||||
group = "acme";
|
||||
reloadServices = [ "haproxy.service" ];
|
||||
};
|
||||
};
|
||||
|
||||
acmeServices =
|
||||
map (domain: "acme-${domain}.tmmworkshop.com.service") domains
|
||||
++ map (domain: "acme-${domain}.service") extraDomains;
|
||||
in
|
||||
{
|
||||
users.users.haproxy.extraGroups = [ "acme" ];
|
||||
|
||||
security.acme = {
|
||||
acceptTerms = true;
|
||||
defaults.email = "Richie@tmmworkshop.com";
|
||||
certs = builtins.listToAttrs ((map makeCert domains) ++ (map makeExtraCert extraDomains));
|
||||
};
|
||||
|
||||
# Minimal nginx to serve ACME HTTP-01 challenge files for HAProxy
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts."acme-challenge" = {
|
||||
listen = [
|
||||
{
|
||||
addr = "127.0.0.1";
|
||||
port = 8402;
|
||||
}
|
||||
];
|
||||
locations."/.well-known/acme-challenge/" = {
|
||||
root = "/var/lib/acme/.challenges";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Ensure the challenge directory exists with correct permissions
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/acme/.challenges 0750 acme acme - -"
|
||||
"d /var/lib/acme/.challenges/.well-known 0750 acme acme - -"
|
||||
"d /var/lib/acme/.challenges/.well-known/acme-challenge 0750 acme acme - -"
|
||||
];
|
||||
|
||||
users.users.nginx.extraGroups = [ "acme" ];
|
||||
|
||||
# HAProxy needs certs to exist before it can bind :443.
|
||||
# NixOS's acme module generates self-signed placeholders on first boot
|
||||
# via acme-<domain>.service — just make HAProxy wait for them.
|
||||
systemd.services.haproxy = {
|
||||
after = acmeServices;
|
||||
wants = acmeServices;
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{ lib, ... }:
|
||||
{
|
||||
imports =
|
||||
let
|
||||
files = builtins.attrNames (builtins.readDir ./.);
|
||||
nixFiles = builtins.filter (name: lib.hasSuffix ".nix" name && name != "default.nix") files;
|
||||
in
|
||||
map (file: ./. + "/${file}") nixFiles;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
global
|
||||
log stdout format raw local0
|
||||
# stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
|
||||
stats timeout 30s
|
||||
|
||||
defaults
|
||||
log global
|
||||
mode http
|
||||
option httplog
|
||||
retries 3
|
||||
maxconn 2000
|
||||
timeout connect 5s
|
||||
timeout client 50s
|
||||
timeout server 50s
|
||||
timeout http-request 10s
|
||||
timeout http-keep-alive 2s
|
||||
timeout queue 5s
|
||||
timeout tunnel 2m
|
||||
timeout client-fin 1s
|
||||
timeout server-fin 1s
|
||||
|
||||
|
||||
#Application Setup
|
||||
frontend ContentSwitching
|
||||
bind *:80 v4v6
|
||||
bind *:443 v4v6 ssl crt /var/lib/acme/audiobookshelf.tmmworkshop.com/full.pem crt /var/lib/acme/cache.tmmworkshop.com/full.pem crt /var/lib/acme/gems.tmmworkshop.com/full.pem crt /var/lib/acme/jellyfin.tmmworkshop.com/full.pem crt /var/lib/acme/share.tmmworkshop.com/full.pem crt /var/lib/acme/gitea.tmmworkshop.com/full.pem crt /var/lib/acme/www.norn-sight.com/full.pem
|
||||
mode http
|
||||
|
||||
# ACME challenge routing (must be first)
|
||||
acl is_acme path_beg /.well-known/acme-challenge/
|
||||
|
||||
# Host ACLs (defined early so rate-limiting can scope to a single vhost)
|
||||
acl host_audiobookshelf hdr(host) -i audiobookshelf.tmmworkshop.com
|
||||
acl host_cache hdr(host) -i cache.tmmworkshop.com
|
||||
acl host_jellyfin hdr(host) -i jellyfin.tmmworkshop.com
|
||||
acl host_share hdr(host) -i share.tmmworkshop.com
|
||||
acl host_gitea hdr(host) -i gitea.tmmworkshop.com
|
||||
acl host_gems hdr(host) -i gems.tmmworkshop.com
|
||||
acl host_norn_sight hdr(host) -i www.norn-sight.com
|
||||
|
||||
# --- Request logging ---
|
||||
# Capture the Host header and User-Agent so the httplog shows who is
|
||||
# requesting what. They appear in the log's {captured|headers} field,
|
||||
# in this order: {host|user-agent}. Client IP is already logged by httplog.
|
||||
# Kept above the rate limiting so denied (429) requests are captured too.
|
||||
http-request capture req.hdr(Host) len 100
|
||||
http-request capture req.hdr(User-Agent) len 128
|
||||
|
||||
# --- robots.txt ---
|
||||
# Serve a single global robots.txt for every vhost (asks crawlers to wait
|
||||
# 10s between requests via Crawl-delay). Returned for both HTTP and HTTPS.
|
||||
# File is deployed to /etc/haproxy/robots.txt by haproxy.nix.
|
||||
# Kept above the rate limiting so crawlers can always read it: `return` is a
|
||||
# terminating action, so robots.txt requests are never tracked or denied.
|
||||
acl is_robots path /robots.txt
|
||||
http-request return status 200 content-type "text/plain" file /etc/haproxy/robots.txt if is_robots
|
||||
|
||||
# --- Rate limiting (Gitea only, per source IP) ---
|
||||
# Trusted devices exempt from rate limiting (add one line per IP/CIDR).
|
||||
# Internal / reserved-for-private-use ranges:
|
||||
# IPv4: RFC 1918 private, loopback, link-local
|
||||
acl rate_limit_allowlist src 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8 169.254.0.0/16
|
||||
# IPv6: loopback, unique local (ULA), link-local
|
||||
acl rate_limit_allowlist src ::1/128 fc00::/7 fe80::/10
|
||||
# Add specific public devices below as needed:
|
||||
# acl rate_limit_allowlist src 192.0.2.50
|
||||
|
||||
# Track HTTP request rate per client IP over a 10s sliding window. Only Gitea
|
||||
# is rate-limited; all other vhosts are left alone.
|
||||
# ipv6 table type also covers IPv4 (mapped), so it works for both binds.
|
||||
stick-table type ipv6 size 100k expire 30s store http_req_rate(10s)
|
||||
http-request track-sc0 src if host_gitea !is_acme !rate_limit_allowlist
|
||||
# Threshold: deny (429) when a client exceeds this many requests per 10s.
|
||||
# Kept loose (50/10s) since logged-in users are rate-limited too; a page
|
||||
# load can burst a few dozen asset requests.
|
||||
acl over_rate_limit sc_http_req_rate(0) gt 50
|
||||
http-request deny deny_status 429 if over_rate_limit host_gitea !is_acme !rate_limit_allowlist
|
||||
|
||||
# --- Per-endpoint limit: Gitea compare/diff is expensive; cap at 1 req / 5 min / IP ---
|
||||
# Tracked in a separate 5-minute table (st_compare) since a proxy has only one
|
||||
# inline stick-table. Allow-listed (internal) IPs are exempt.
|
||||
# Matches /<owner>/<repo>/compare on every repo; -i because Gitea routes are
|
||||
# case-insensitive.
|
||||
acl is_gitea_compare path_reg -i ^/[^/]+/[^/]+/compare
|
||||
http-request track-sc1 src table st_compare if host_gitea is_gitea_compare !rate_limit_allowlist
|
||||
http-request deny deny_status 429 if host_gitea is_gitea_compare !rate_limit_allowlist { sc_http_req_rate(1,st_compare) gt 1 }
|
||||
|
||||
# Anonymous Gems rooms use high-entropy invitation codes, with an additional
|
||||
# per-IP limit on room creation and join attempts.
|
||||
acl gems_entry path -i /rooms
|
||||
acl gems_join path_beg -i /join
|
||||
acl request_post method POST
|
||||
http-request track-sc2 src table st_gems_join if host_gems request_post gems_entry
|
||||
http-request track-sc2 src table st_gems_join if host_gems request_post gems_join
|
||||
http-request deny deny_status 429 if host_gems { sc_http_req_rate(2,st_gems_join) gt 20 }
|
||||
|
||||
# Hosts allowed to serve plain HTTP (add entries to skip the HTTPS redirect)
|
||||
acl allow_http hdr(host) -i __none__
|
||||
# acl allow_http hdr(host) -i example.tmmworkshop.com
|
||||
|
||||
# Redirect all HTTP to HTTPS unless on the allow list or ACME challenge
|
||||
http-request redirect scheme https code 301 if !{ ssl_fc } !allow_http !is_acme
|
||||
|
||||
use_backend acme_challenge if is_acme
|
||||
use_backend audiobookshelf_nodes if host_audiobookshelf
|
||||
use_backend cache_nodes if host_cache
|
||||
use_backend jellyfin if host_jellyfin
|
||||
use_backend share_nodes if host_share
|
||||
use_backend gitea if host_gitea
|
||||
use_backend gems if host_gems
|
||||
use_backend norn_sight if host_norn_sight
|
||||
|
||||
# Stick-table only (no servers): tracks per-IP request rate to Gitea's compare
|
||||
# endpoint over a 5-minute window so the frontend can cap it at 1 per 5 min.
|
||||
backend st_compare
|
||||
stick-table type ipv6 size 100k expire 600s store http_req_rate(300s)
|
||||
|
||||
backend st_gems_join
|
||||
stick-table type ipv6 size 100k expire 120s store http_req_rate(60s)
|
||||
|
||||
backend acme_challenge
|
||||
mode http
|
||||
server acme 127.0.0.1:8402
|
||||
|
||||
backend audiobookshelf_nodes
|
||||
mode http
|
||||
server server 127.0.0.1:8000
|
||||
|
||||
backend cache_nodes
|
||||
mode http
|
||||
server server 127.0.0.1:5000
|
||||
|
||||
backend jellyfin
|
||||
mode http
|
||||
option forwardfor
|
||||
server jellyfin 127.0.0.1:8096
|
||||
|
||||
backend share_nodes
|
||||
mode http
|
||||
server server 127.0.0.1:8091
|
||||
|
||||
backend gitea
|
||||
mode http
|
||||
server server 127.0.0.1:6443
|
||||
|
||||
backend gems
|
||||
mode http
|
||||
option forwardfor
|
||||
timeout server 1h
|
||||
server gems 127.0.0.1:8002
|
||||
|
||||
backend norn_sight
|
||||
mode http
|
||||
server server 127.0.0.1:8001
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
80
|
||||
443
|
||||
];
|
||||
|
||||
# Global robots.txt served by HAProxy for every vhost (see haproxy.cfg).
|
||||
environment.etc."haproxy/robots.txt".source = ./robots.txt;
|
||||
|
||||
services.haproxy = {
|
||||
enable = true;
|
||||
config = builtins.readFile ./haproxy.cfg;
|
||||
};
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Crawl-delay: 10
|
||||
Reference in New Issue
Block a user