1{ lib, pkgs, ... }:
2let
3 wg-keys = import ./wireguard/snakeoil-keys.nix;
4
5 target_host = "acme.test";
6 server_host = "sing-box.test";
7
8 hosts = {
9 "${target_host}" = "1.1.1.1";
10 "${server_host}" = "1.1.1.2";
11 };
12 hostsEntries = lib.mapAttrs' (k: v: {
13 name = v;
14 value = lib.singleton k;
15 }) hosts;
16
17 vmessPort = 1080;
18 vmessUUID = "bf000d23-0752-40b4-affe-68f7707a9661";
19 vmessInbound = {
20 type = "vmess";
21 tag = "inbound:vmess";
22 listen = "0.0.0.0";
23 listen_port = vmessPort;
24 users = [
25 {
26 name = "sekai";
27 uuid = vmessUUID;
28 alterId = 0;
29 }
30 ];
31 };
32 vmessOutbound = {
33 type = "vmess";
34 tag = "outbound:vmess";
35 server = server_host;
36 server_port = vmessPort;
37 uuid = vmessUUID;
38 security = "auto";
39 alter_id = 0;
40 };
41
42 tunInbound = {
43 type = "tun";
44 tag = "inbound:tun";
45 interface_name = "tun0";
46 address = [
47 "172.16.0.1/30"
48 "fd00::1/126"
49 ];
50 auto_route = true;
51 iproute2_table_index = 2024;
52 iproute2_rule_index = 9001;
53 route_address = [
54 "${hosts."${target_host}"}/32"
55 ];
56 route_exclude_address = [
57 "${hosts."${server_host}"}/32"
58 ];
59 strict_route = false;
60 };
61
62 tproxyPort = 1081;
63 tproxyPost = pkgs.writeShellApplication {
64 name = "exe";
65 runtimeInputs = with pkgs; [
66 iproute2
67 iptables
68 ];
69 text = ''
70 ip route add local default dev lo table 100
71 ip rule add fwmark 1 table 100
72
73 iptables -t mangle -N SING_BOX
74 iptables -t mangle -A SING_BOX -d 100.64.0.0/10 -j RETURN
75 iptables -t mangle -A SING_BOX -d 127.0.0.0/8 -j RETURN
76 iptables -t mangle -A SING_BOX -d 169.254.0.0/16 -j RETURN
77 iptables -t mangle -A SING_BOX -d 172.16.0.0/12 -j RETURN
78 iptables -t mangle -A SING_BOX -d 192.0.0.0/24 -j RETURN
79 iptables -t mangle -A SING_BOX -d 224.0.0.0/4 -j RETURN
80 iptables -t mangle -A SING_BOX -d 240.0.0.0/4 -j RETURN
81 iptables -t mangle -A SING_BOX -d 255.255.255.255/32 -j RETURN
82
83 iptables -t mangle -A SING_BOX -d ${hosts."${server_host}"}/32 -p tcp -j RETURN
84 iptables -t mangle -A SING_BOX -d ${hosts."${server_host}"}/32 -p udp -j RETURN
85
86 iptables -t mangle -A SING_BOX -d ${hosts."${target_host}"}/32 -p tcp -j TPROXY --on-port ${toString tproxyPort} --tproxy-mark 1
87 iptables -t mangle -A SING_BOX -d ${hosts."${target_host}"}/32 -p udp -j TPROXY --on-port ${toString tproxyPort} --tproxy-mark 1
88 iptables -t mangle -A PREROUTING -j SING_BOX
89
90 iptables -t mangle -N SING_BOX_SELF
91 iptables -t mangle -A SING_BOX_SELF -d 100.64.0.0/10 -j RETURN
92 iptables -t mangle -A SING_BOX_SELF -d 127.0.0.0/8 -j RETURN
93 iptables -t mangle -A SING_BOX_SELF -d 169.254.0.0/16 -j RETURN
94 iptables -t mangle -A SING_BOX_SELF -d 172.16.0.0/12 -j RETURN
95 iptables -t mangle -A SING_BOX_SELF -d 192.0.0.0/24 -j RETURN
96 iptables -t mangle -A SING_BOX_SELF -d 224.0.0.0/4 -j RETURN
97 iptables -t mangle -A SING_BOX_SELF -d 240.0.0.0/4 -j RETURN
98 iptables -t mangle -A SING_BOX_SELF -d 255.255.255.255/32 -j RETURN
99 iptables -t mangle -A SING_BOX_SELF -j RETURN -m mark --mark 1234
100
101 iptables -t mangle -A SING_BOX_SELF -d ${hosts."${server_host}"}/32 -p tcp -j RETURN
102 iptables -t mangle -A SING_BOX_SELF -d ${hosts."${server_host}"}/32 -p udp -j RETURN
103 iptables -t mangle -A SING_BOX_SELF -p tcp -j MARK --set-mark 1
104 iptables -t mangle -A SING_BOX_SELF -p udp -j MARK --set-mark 1
105 iptables -t mangle -A OUTPUT -j SING_BOX_SELF
106 '';
107 };
108in
109{
110
111 name = "sing-box";
112
113 meta = {
114 maintainers = with lib.maintainers; [
115 nickcao
116 prince213
117 ];
118 };
119
120 nodes = {
121 target =
122 { pkgs, ... }:
123 {
124 networking = {
125 firewall.enable = false;
126 hosts = hostsEntries;
127 useDHCP = false;
128 interfaces.eth1 = {
129 ipv4.addresses = [
130 {
131 address = hosts."${target_host}";
132 prefixLength = 24;
133 }
134 ];
135 };
136 };
137
138 services.dnsmasq.enable = true;
139
140 services.nginx = {
141 enable = true;
142 package = pkgs.nginxQuic;
143
144 virtualHosts."${target_host}" = {
145 onlySSL = true;
146 sslCertificate = ./common/acme/server/acme.test.cert.pem;
147 sslCertificateKey = ./common/acme/server/acme.test.key.pem;
148 http2 = true;
149 http3 = true;
150 http3_hq = false;
151 quic = true;
152 reuseport = true;
153 locations."/" = {
154 extraConfig = ''
155 default_type text/plain;
156 return 200 "$server_protocol $remote_addr";
157 allow ${hosts."${server_host}"}/32;
158 deny all;
159 '';
160 };
161 };
162 };
163 };
164
165 server =
166 { pkgs, ... }:
167 {
168 boot.kernel.sysctl = {
169 "net.ipv4.conf.all.forwarding" = 1;
170 };
171
172 networking = {
173 firewall.enable = false;
174 hosts = hostsEntries;
175 useDHCP = false;
176 interfaces.eth1 = {
177 ipv4.addresses = [
178 {
179 address = hosts."${server_host}";
180 prefixLength = 24;
181 }
182 ];
183 };
184 };
185
186 systemd.network.wait-online.ignoredInterfaces = [ "wg0" ];
187
188 networking.wg-quick.interfaces.wg0 = {
189 address = [
190 "10.23.42.1/24"
191 ];
192 listenPort = 2408;
193 mtu = 1500;
194
195 inherit (wg-keys.peer0) privateKey;
196
197 peers = lib.singleton {
198 allowedIPs = [
199 "10.23.42.2/32"
200 ];
201
202 inherit (wg-keys.peer1) publicKey;
203 };
204
205 postUp = ''
206 ${pkgs.iptables}/bin/iptables -A FORWARD -i wg0 -j ACCEPT
207 ${pkgs.iptables}/bin/iptables -t nat -A POSTROUTING -s 10.23.42.0/24 -o eth1 -j MASQUERADE
208 '';
209 };
210
211 services.sing-box = {
212 enable = true;
213 settings = {
214 inbounds = [
215 vmessInbound
216 ];
217 outbounds = [
218 {
219 type = "direct";
220 tag = "outbound:direct";
221 }
222 ];
223 route = {
224 default_interface = "eth1";
225 };
226 };
227 };
228 };
229
230 tun =
231 { pkgs, ... }:
232 {
233 networking = {
234 firewall.enable = false;
235 hosts = hostsEntries;
236 useDHCP = false;
237 interfaces.eth1 = {
238 ipv4.addresses = [
239 {
240 address = "1.1.1.3";
241 prefixLength = 24;
242 }
243 ];
244 };
245 };
246
247 security.pki.certificates = [
248 (builtins.readFile ./common/acme/server/ca.cert.pem)
249 ];
250
251 environment.systemPackages = [
252 pkgs.curlHTTP3
253 pkgs.iproute2
254 ];
255
256 services.sing-box = {
257 enable = true;
258 settings = {
259 inbounds = [
260 tunInbound
261 ];
262 outbounds = [
263 {
264 type = "block";
265 tag = "outbound:block";
266 }
267 {
268 type = "direct";
269 tag = "outbound:direct";
270 }
271 vmessOutbound
272 ];
273 route = {
274 default_interface = "eth1";
275 final = "outbound:block";
276 rules = [
277 {
278 inbound = [
279 "inbound:tun"
280 ];
281 outbound = "outbound:vmess";
282 }
283 ];
284 };
285 };
286 };
287 };
288
289 wireguard =
290 { pkgs, ... }:
291 {
292 networking = {
293 firewall.enable = false;
294 hosts = hostsEntries;
295 useDHCP = false;
296 interfaces.eth1 = {
297 ipv4.addresses = [
298 {
299 address = "1.1.1.4";
300 prefixLength = 24;
301 }
302 ];
303 };
304 };
305
306 security.pki.certificates = [
307 (builtins.readFile ./common/acme/server/ca.cert.pem)
308 ];
309
310 environment.systemPackages = [
311 pkgs.curlHTTP3
312 pkgs.iproute2
313 ];
314
315 services.sing-box = {
316 enable = true;
317 settings = {
318 outbounds = [
319 {
320 type = "block";
321 tag = "outbound:block";
322 }
323 ];
324 endpoints = [
325 {
326 type = "wireguard";
327 tag = "outbound:wireguard";
328 name = "wg0";
329 address = [ "10.23.42.2/32" ];
330 mtu = 1280;
331 private_key = wg-keys.peer1.privateKey;
332 peers = [
333 {
334 address = server_host;
335 port = 2408;
336 public_key = wg-keys.peer0.publicKey;
337 allowed_ips = [ "0.0.0.0/0" ];
338 }
339 ];
340 system = true;
341 }
342 ];
343 route = {
344 default_interface = "eth1";
345 final = "outbound:block";
346 };
347 };
348 };
349 };
350
351 tproxy =
352 { pkgs, ... }:
353 {
354 networking = {
355 firewall.enable = false;
356 hosts = hostsEntries;
357 useDHCP = false;
358 interfaces.eth1 = {
359 ipv4.addresses = [
360 {
361 address = "1.1.1.5";
362 prefixLength = 24;
363 }
364 ];
365 };
366 };
367
368 security.pki.certificates = [
369 (builtins.readFile ./common/acme/server/ca.cert.pem)
370 ];
371
372 environment.systemPackages = [ pkgs.curlHTTP3 ];
373
374 systemd.services.sing-box.serviceConfig.ExecStartPost = [
375 "+${tproxyPost}/bin/exe"
376 ];
377
378 services.sing-box = {
379 enable = true;
380 settings = {
381 inbounds = [
382 {
383 tag = "inbound:tproxy";
384 type = "tproxy";
385 listen = "0.0.0.0";
386 listen_port = tproxyPort;
387 udp_fragment = true;
388 }
389 ];
390 outbounds = [
391 {
392 type = "block";
393 tag = "outbound:block";
394 }
395 {
396 type = "direct";
397 tag = "outbound:direct";
398 }
399 vmessOutbound
400 ];
401 route = {
402 default_interface = "eth1";
403 final = "outbound:block";
404 rules = [
405 {
406 inbound = [
407 "inbound:tproxy"
408 ];
409 outbound = "outbound:vmess";
410 }
411 ];
412 };
413 };
414 };
415 };
416
417 fakeip =
418 { pkgs, ... }:
419 {
420 networking = {
421 firewall.enable = false;
422 hosts = hostsEntries;
423 useDHCP = false;
424 interfaces.eth1 = {
425 ipv4.addresses = [
426 {
427 address = "1.1.1.6";
428 prefixLength = 24;
429 }
430 ];
431 };
432 };
433
434 environment.systemPackages = [ pkgs.dnsutils ];
435
436 services.sing-box = {
437 enable = true;
438 settings = {
439 dns = {
440 final = "dns:default";
441 independent_cache = true;
442 servers = [
443 {
444 type = "udp";
445 tag = "dns:default";
446 server = hosts."${target_host}";
447 }
448 {
449 type = "fakeip";
450 tag = "dns:fakeip";
451 inet4_range = "198.18.0.0/16";
452 }
453 {
454 type = "resolved";
455 tag = "dns:resolved";
456 service = "service:resolved";
457 accept_default_resolvers = true;
458 }
459 ];
460 rules = [
461 {
462 query_type = [
463 "A"
464 "AAAA"
465 ];
466 server = "dns:fakeip";
467 }
468 ];
469 };
470 inbounds = [
471 tunInbound
472 ];
473 outbounds = [
474 {
475 type = "block";
476 tag = "outbound:block";
477 }
478 {
479 type = "direct";
480 tag = "outbound:direct";
481 }
482 ];
483 route = {
484 default_domain_resolver = "dns:default";
485 default_interface = "eth1";
486 final = "outbound:direct";
487 rules = [
488 {
489 action = "sniff";
490 }
491 {
492 protocol = "dns";
493 action = "hijack-dns";
494 }
495 ];
496 };
497 services = [
498 {
499 type = "resolved";
500 tag = "service:resolved";
501 }
502 ];
503 };
504 };
505 };
506 };
507
508 testScript = ''
509 target.wait_for_unit("nginx.service")
510 target.wait_for_open_port(443)
511 target.wait_for_unit("dnsmasq.service")
512 target.wait_for_open_port(53)
513
514 server.wait_for_unit("sing-box.service")
515 server.wait_for_open_port(1080)
516 server.wait_for_unit("wg-quick-wg0.service")
517 server.wait_for_file("/sys/class/net/wg0")
518
519 def test_curl(machine, extra_args=""):
520 assert (
521 machine.succeed(f"curl --fail --max-time 10 --http2 https://${target_host} {extra_args}")
522 == "HTTP/2.0 ${hosts.${server_host}}"
523 )
524 assert (
525 machine.succeed(f"curl --fail --max-time 10 --http3-only https://${target_host} {extra_args}")
526 == "HTTP/3.0 ${hosts.${server_host}}"
527 )
528
529 with subtest("tun"):
530 tun.wait_for_unit("sing-box.service")
531 tun.wait_for_unit("sys-devices-virtual-net-${tunInbound.interface_name}.device")
532 tun.wait_until_succeeds("ip route get ${hosts."${target_host}"} | grep 'dev ${tunInbound.interface_name}'")
533 tun.succeed("ip addr show ${tunInbound.interface_name}")
534 tun.succeed("ip route show table ${toString tunInbound.iproute2_table_index} | grep ${tunInbound.interface_name}")
535 assert (
536 tun.succeed("ip rule list table ${toString tunInbound.iproute2_table_index} | sort | head -1 | awk -F: '{print $1}' | tr -d '\n'")
537 == "${toString tunInbound.iproute2_rule_index}"
538 )
539 test_curl(tun)
540
541 with subtest("wireguard"):
542 wireguard.wait_for_unit("sing-box.service")
543 wireguard.wait_for_unit("sys-devices-virtual-net-wg0.device")
544 wireguard.succeed("ip addr show wg0")
545 test_curl(wireguard, "--interface wg0")
546
547 with subtest("tproxy"):
548 tproxy.wait_for_unit("sing-box.service")
549 test_curl(tproxy)
550
551 with subtest("fakeip"):
552 fakeip.wait_for_unit("sing-box.service")
553 fakeip.wait_for_unit("sys-devices-virtual-net-${tunInbound.interface_name}.device")
554 fakeip.wait_until_succeeds("ip route get ${hosts."${target_host}"} | grep 'dev ${tunInbound.interface_name}'")
555 fakeip.succeed("dig +short A ${target_host} @${target_host} | grep '^198.18.'")
556 '';
557
558}