1{
2 lib,
3 pkgs,
4 config,
5 ...
6}:
7let
8 inherit (lib)
9 mkOption
10 types
11 mkIf
12 optionalString
13 ;
14 cfg = config.services.opengfw;
15in
16{
17 options.services.opengfw = {
18 enable = lib.mkEnableOption ''
19 OpenGFW, A flexible, easy-to-use, open source implementation of GFW on Linux
20 '';
21
22 package = lib.mkPackageOption pkgs "opengfw" { default = "opengfw"; };
23
24 user = mkOption {
25 default = "opengfw";
26 type = types.singleLineStr;
27 description = "Username of the OpenGFW user.";
28 };
29
30 dir = mkOption {
31 default = "/var/lib/opengfw";
32 type = types.singleLineStr;
33 description = ''
34 Working directory of the OpenGFW service and home of `opengfw.user`.
35 '';
36 };
37
38 logFile = mkOption {
39 default = null;
40 type = types.nullOr types.path;
41 example = "/var/lib/opengfw/opengfw.log";
42 description = ''
43 File to write the output to instead of systemd.
44 '';
45 };
46
47 logFormat = mkOption {
48 description = ''
49 Format of the logs. [logFormatMap](https://github.com/apernet/OpenGFW/blob/d7737e92117a11c9a6100d53019fac3b9d724fe3/cmd/root.go#L62)
50 '';
51 default = "json";
52 example = "console";
53 type = types.enum [
54 "json"
55 "console"
56 ];
57 };
58
59 pcapReplay = mkOption {
60 default = null;
61 example = "./opengfw.pcap";
62 type = types.nullOr types.path;
63 description = ''
64 Path to PCAP replay file.
65 In pcap mode, none of the actions in the rules have any effect.
66 This mode is mainly for debugging.
67 '';
68 };
69
70 logLevel = mkOption {
71 description = ''
72 Level of the logs. [logLevelMap](https://github.com/apernet/OpenGFW/blob/d7737e92117a11c9a6100d53019fac3b9d724fe3/cmd/root.go#L55)
73 '';
74 default = "info";
75 example = "warn";
76 type = types.enum [
77 "debug"
78 "info"
79 "warn"
80 "error"
81 ];
82 };
83
84 rulesFile = mkOption {
85 default = null;
86 type = types.nullOr types.path;
87 description = ''
88 Path to file containing OpenGFW rules.
89 '';
90 };
91
92 settingsFile = mkOption {
93 default = null;
94 type = types.nullOr types.path;
95 description = ''
96 Path to file containing OpenGFW settings.
97 '';
98 };
99
100 settings = mkOption {
101 default = null;
102 description = ''
103 Settings passed to OpenGFW. [Example config](https://gfw.dev/docs/build-run/#config-example)
104 '';
105 type = types.nullOr (
106 types.submodule {
107 options = {
108 replay = mkOption {
109 description = ''
110 PCAP replay settings.
111 '';
112 default = { };
113 type = types.submodule {
114 options = {
115 realtime = mkOption {
116 description = ''
117 Whether the packets in the PCAP file should be replayed in "real time" (instead of as fast as possible).
118 '';
119 default = false;
120 example = true;
121 type = types.bool;
122 };
123 };
124 };
125 };
126
127 io = mkOption {
128 description = ''
129 IO settings.
130 '';
131 default = { };
132 type = types.submodule {
133 options = {
134 queueSize = mkOption {
135 description = "IO queue size.";
136 type = types.int;
137 default = 1024;
138 example = 2048;
139 };
140 local = mkOption {
141 description = ''
142 Set to false if you want to run OpenGFW on FORWARD chain. (e.g. on a router)
143 '';
144 type = types.bool;
145 default = true;
146 example = false;
147 };
148 rst = mkOption {
149 description = ''
150 Set to true if you want to send RST for blocked TCP connections, needs `local = false`.
151 '';
152 type = types.bool;
153 default = !cfg.settings.io.local;
154 defaultText = "`!config.services.opengfw.settings.io.local`";
155 example = false;
156 };
157 rcvBuf = mkOption {
158 description = "Netlink receive buffer size.";
159 type = types.int;
160 default = 4194304;
161 example = 2097152;
162 };
163 sndBuf = mkOption {
164 description = "Netlink send buffer size.";
165 type = types.int;
166 default = 4194304;
167 example = 2097152;
168 };
169 };
170 };
171 };
172 ruleset = mkOption {
173 description = ''
174 The path to load specific local geoip/geosite db files.
175 If not set, they will be automatically downloaded from [Loyalsoldier/v2ray-rules-dat](https://github.com/Loyalsoldier/v2ray-rules-dat).
176 '';
177 default = { };
178 type = types.submodule {
179 options = {
180 geoip = mkOption {
181 description = "Path to `geoip.dat`.";
182 default = null;
183 type = types.nullOr types.path;
184 };
185 geosite = mkOption {
186 description = "Path to `geosite.dat`.";
187 default = null;
188 type = types.nullOr types.path;
189 };
190 };
191 };
192 };
193 workers = mkOption {
194 default = { };
195 description = "Worker settings.";
196 type = types.submodule {
197 options = {
198 count = mkOption {
199 type = types.int;
200 description = ''
201 Number of workers.
202 Recommended to be no more than the number of CPU cores
203 '';
204 default = 4;
205 example = 8;
206 };
207 queueSize = mkOption {
208 type = types.int;
209 description = "Worker queue size.";
210 default = 16;
211 example = 32;
212 };
213 tcpMaxBufferedPagesTotal = mkOption {
214 type = types.int;
215 description = ''
216 TCP max total buffered pages.
217 '';
218 default = 4096;
219 example = 8192;
220 };
221 tcpMaxBufferedPagesPerConn = mkOption {
222 type = types.int;
223 description = ''
224 TCP max total bufferd pages per connection.
225 '';
226 default = 64;
227 example = 128;
228 };
229 tcpTimeout = mkOption {
230 type = types.str;
231 description = ''
232 How long a connection is considered dead when no data is being transferred.
233 Dead connections are purged from TCP reassembly pools once per minute.
234 '';
235 default = "10m";
236 example = "5m";
237 };
238 udpMaxStreams = mkOption {
239 type = types.int;
240 description = "UDP max streams.";
241 default = 4096;
242 example = 8192;
243 };
244 };
245 };
246 };
247 };
248 }
249 );
250 };
251
252 rules = mkOption {
253 default = [ ];
254 description = ''
255 Rules passed to OpenGFW. [Example rules](https://gfw.dev/docs/rules)
256 '';
257 type = types.listOf (
258 types.submodule {
259 options = {
260 name = mkOption {
261 description = "Name of the rule.";
262 example = "block google dns";
263 type = types.singleLineStr;
264 };
265
266 action = mkOption {
267 description = ''
268 Action of the rule. [Supported actions](https://gfw.dev/docs/rules#supported-actions)
269 '';
270 default = "allow";
271 example = "block";
272 type = types.enum [
273 "allow"
274 "block"
275 "drop"
276 "modify"
277 ];
278 };
279
280 log = mkOption {
281 description = "Whether to enable logging for the rule.";
282 default = true;
283 example = false;
284 type = types.bool;
285 };
286
287 expr = mkOption {
288 description = ''
289 [Expr Language](https://expr-lang.org/docs/language-definition) expression using [analyzers](https://gfw.dev/docs/analyzers) and [functions](https://gfw.dev/docs/functions).
290 '';
291 type = types.str;
292 example = ''dns != nil && dns.qr && any(dns.questions, {.name endsWith "google.com"})'';
293 };
294
295 modifier = mkOption {
296 default = null;
297 description = ''
298 Modification of specified packets when using the `modify` action. [Available modifiers](https://github.com/apernet/OpenGFW/tree/master/modifier)
299 '';
300 type = types.nullOr (
301 types.submodule {
302 options = {
303 name = mkOption {
304 description = "Name of the modifier.";
305 type = types.singleLineStr;
306 example = "dns";
307 };
308
309 args = mkOption {
310 description = "Arguments passed to the modifier.";
311 type = types.attrs;
312 example = {
313 a = "0.0.0.0";
314 aaaa = "::";
315 };
316 };
317 };
318 }
319 );
320 };
321 };
322 }
323 );
324
325 example = [
326 {
327 name = "block v2ex http";
328 action = "block";
329 expr = ''string(http?.req?.headers?.host) endsWith "v2ex.com"'';
330 }
331 {
332 name = "block google socks";
333 action = "block";
334 expr = ''string(socks?.req?.addr) endsWith "google.com" && socks?.req?.port == 80'';
335 }
336 {
337 name = "v2ex dns poisoning";
338 action = "modify";
339 modifier = {
340 name = "dns";
341 args = {
342 a = "0.0.0.0";
343 aaaa = "::";
344 };
345 };
346 expr = ''dns != nil && dns.qr && any(dns.questions, {.name endsWith "v2ex.com"})'';
347 }
348 ];
349 };
350 };
351
352 config =
353 let
354 format = pkgs.formats.yaml { };
355
356 settings =
357 if cfg.settings != null then
358 format.generate "opengfw-config.yaml" cfg.settings
359 else
360 cfg.settingsFile;
361 rules = if cfg.rules != [ ] then format.generate "opengfw-rules.yaml" cfg.rules else cfg.rulesFile;
362 in
363 mkIf cfg.enable {
364 security.wrappers.OpenGFW = {
365 owner = cfg.user;
366 group = cfg.user;
367 capabilities = "cap_net_admin+ep";
368 source = "${cfg.package}/bin/OpenGFW";
369 };
370
371 systemd.services.opengfw = {
372 description = "OpenGFW";
373 wantedBy = [ "multi-user.target" ];
374 after = [ "network.target" ];
375 path = with pkgs; [ iptables ];
376
377 preStart = ''
378 ${optionalString (rules != null) "ln -sf ${rules} rules.yaml"}
379 ${optionalString (settings != null) "ln -sf ${settings} config.yaml"}
380 '';
381
382 script = ''
383 ${config.security.wrapperDir}/OpenGFW \
384 -f ${cfg.logFormat} \
385 -l ${cfg.logLevel} \
386 ${optionalString (cfg.pcapReplay != null) "-p ${cfg.pcapReplay}"} \
387 -c config.yaml \
388 rules.yaml
389 '';
390
391 serviceConfig = rec {
392 WorkingDirectory = cfg.dir;
393 ExecReload = "kill -HUP $MAINPID";
394 Restart = "always";
395 User = cfg.user;
396 StandardOutput = mkIf (cfg.logFile != null) "append:${cfg.logFile}";
397 StandardError = StandardOutput;
398 };
399 };
400
401 users = {
402 groups.${cfg.user} = { };
403 users.${cfg.user} = {
404 description = "opengfw user";
405 isSystemUser = true;
406 group = cfg.user;
407 home = cfg.dir;
408 createHome = true;
409 homeMode = "750";
410 };
411 };
412 };
413 meta.maintainers = with lib.maintainers; [ eum3l ];
414}