Kieran's opinionated (and probably slightly dumb) nix config
1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.atelier.services.frps;
9in
10{
11 options.atelier.services.frps = {
12 enable = lib.mkEnableOption "frp server for tunneling services";
13
14 bindAddr = lib.mkOption {
15 type = lib.types.str;
16 default = "0.0.0.0";
17 description = "Address to bind frp server to";
18 };
19
20 bindPort = lib.mkOption {
21 type = lib.types.port;
22 default = 7000;
23 description = "Port for frp control connection";
24 };
25
26 vhostHTTPPort = lib.mkOption {
27 type = lib.types.port;
28 default = 7080;
29 description = "Port for HTTP virtual host traffic";
30 };
31
32 allowedTCPPorts = lib.mkOption {
33 type = lib.types.listOf lib.types.port;
34 default = lib.lists.range 20000 20099;
35 example = [ 20000 20001 20002 20003 20004 ];
36 description = "TCP port range to allow for TCP tunnels (default: 20000-20099)";
37 };
38
39 allowedUDPPorts = lib.mkOption {
40 type = lib.types.listOf lib.types.port;
41 default = lib.lists.range 20000 20099;
42 example = [ 20000 20001 20002 20003 20004 ];
43 description = "UDP port range to allow for UDP tunnels (default: 20000-20099)";
44 };
45
46 authToken = lib.mkOption {
47 type = lib.types.nullOr lib.types.str;
48 default = null;
49 description = "Authentication token for clients (deprecated: use authTokenFile)";
50 };
51
52 authTokenFile = lib.mkOption {
53 type = lib.types.nullOr lib.types.path;
54 default = null;
55 description = "Path to file containing authentication token";
56 };
57
58 domain = lib.mkOption {
59 type = lib.types.str;
60 example = "bore.dunkirk.sh";
61 description = "Base domain for subdomains (e.g., *.bore.dunkirk.sh)";
62 };
63
64 enableCaddy = lib.mkOption {
65 type = lib.types.bool;
66 default = true;
67 description = "Automatically configure Caddy reverse proxy for wildcard domain";
68 };
69 };
70
71 config = lib.mkIf cfg.enable {
72 assertions = [
73 {
74 assertion = cfg.authToken != null || cfg.authTokenFile != null;
75 message = "Either authToken or authTokenFile must be set for frps";
76 }
77 ];
78
79 # Open firewall ports for frp control connection and TCP/UDP tunnels
80 networking.firewall.allowedTCPPorts = [ cfg.bindPort ] ++ cfg.allowedTCPPorts;
81 networking.firewall.allowedUDPPorts = cfg.allowedUDPPorts;
82
83 # frp server service
84 systemd.services.frps =
85 let
86 tokenConfig =
87 if cfg.authTokenFile != null then
88 ''
89 auth.tokenSource.type = "file"
90 auth.tokenSource.file.path = "${cfg.authTokenFile}"
91 ''
92 else
93 ''auth.token = "${cfg.authToken}"'';
94
95 configFile = pkgs.writeText "frps.toml" ''
96 bindAddr = "${cfg.bindAddr}"
97 bindPort = ${toString cfg.bindPort}
98 vhostHTTPPort = ${toString cfg.vhostHTTPPort}
99
100 # Dashboard and Prometheus metrics
101 webServer.addr = "127.0.0.1"
102 webServer.port = 7400
103 enablePrometheus = true
104
105 # Authentication token - clients need this to connect
106 auth.method = "token"
107 ${tokenConfig}
108
109 # Subdomain support for *.${cfg.domain}
110 subDomainHost = "${cfg.domain}"
111
112 # Allow port ranges for TCP/UDP tunnels
113 # Format: [[{"start": 20000, "end": 20099}]]
114 allowPorts = [
115 { start = 20000, end = 20099 }
116 ]
117
118 # Custom 404 page
119 custom404Page = "${./404.html}"
120
121 # Logging
122 log.to = "console"
123 log.level = "info"
124 '';
125 in
126 {
127 description = "frp server for ${cfg.domain} tunneling";
128 after = [ "network.target" ];
129 wantedBy = [ "multi-user.target" ];
130 serviceConfig = {
131 Type = "simple";
132 Restart = "on-failure";
133 RestartSec = "5s";
134 ExecStart = "${pkgs.frp}/bin/frps -c ${configFile}";
135 };
136 };
137
138 # Automatically configure Caddy for wildcard domain
139 services.caddy = lib.mkIf cfg.enableCaddy {
140 # Dashboard for base domain
141 virtualHosts."${cfg.domain}" = {
142 extraConfig = ''
143 tls {
144 dns cloudflare {env.CLOUDFLARE_API_TOKEN}
145 }
146 header {
147 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
148 }
149
150 # Proxy /api/* to frps dashboard
151 handle /api/* {
152 reverse_proxy localhost:7400
153 }
154
155 # Serve dashboard HTML
156 handle {
157 root * ${./.}
158 try_files dashboard.html
159 file_server
160 }
161 '';
162 };
163
164 # Wildcard subdomain proxy to frps
165 virtualHosts."*.${cfg.domain}" = {
166 extraConfig = ''
167 tls {
168 dns cloudflare {env.CLOUDFLARE_API_TOKEN}
169 }
170 header {
171 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
172 }
173 reverse_proxy localhost:${toString cfg.vhostHTTPPort} {
174 header_up X-Forwarded-Proto {scheme}
175 header_up X-Forwarded-For {remote}
176 header_up Host {host}
177 }
178 handle_errors {
179 @404 expression {http.error.status_code} == 404
180 handle @404 {
181 root * ${./.}
182 rewrite * /404.html
183 file_server
184 }
185 }
186 '';
187 };
188 };
189 };
190}