1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 cfg = config.services.stargazer;
10 globalSection = ''
11 listen = ${lib.concatStringsSep " " cfg.listen}
12 connection-logging = ${lib.boolToString cfg.connectionLogging}
13 log-ip = ${lib.boolToString cfg.ipLog}
14 log-ip-partial = ${lib.boolToString cfg.ipLogPartial}
15 request-timeout = ${toString cfg.requestTimeout}
16 response-timeout = ${toString cfg.responseTimeout}
17
18 [:tls]
19 store = ${toString cfg.store}
20 organization = ${cfg.certOrg}
21 gen-certs = ${lib.boolToString cfg.genCerts}
22 regen-certs = ${lib.boolToString cfg.regenCerts}
23 ${lib.optionalString (cfg.certLifetime != "") "cert-lifetime = ${cfg.certLifetime}"}
24
25 '';
26 genINI = lib.generators.toINI { };
27 configFile = pkgs.writeText "config.ini" (
28 lib.strings.concatStrings (
29 [ globalSection ]
30 ++ (lib.lists.forEach cfg.routes (
31 section:
32 let
33 name = section.route;
34 params = builtins.removeAttrs section [ "route" ];
35 in
36 genINI {
37 "${name}" = params;
38 }
39 + "\n"
40 ))
41 )
42 );
43in
44{
45 options.services.stargazer = {
46 enable = lib.mkEnableOption "Stargazer Gemini server";
47
48 listen = lib.mkOption {
49 type = lib.types.listOf lib.types.str;
50 default = [ "0.0.0.0" ] ++ lib.optional config.networking.enableIPv6 "[::0]";
51 defaultText = lib.literalExpression ''[ "0.0.0.0" ] ++ lib.optional config.networking.enableIPv6 "[::0]"'';
52 example = lib.literalExpression ''[ "10.0.0.12" "[2002:a00:1::]" ]'';
53 description = ''
54 Address and port to listen on.
55 '';
56 };
57
58 connectionLogging = lib.mkOption {
59 type = lib.types.bool;
60 default = true;
61 description = "Whether or not to log connections to stdout.";
62 };
63
64 ipLog = lib.mkOption {
65 type = lib.types.bool;
66 default = false;
67 description = "Log client IP addresses in the connection log.";
68 };
69
70 ipLogPartial = lib.mkOption {
71 type = lib.types.bool;
72 default = false;
73 description = "Log partial client IP addresses in the connection log.";
74 };
75
76 requestTimeout = lib.mkOption {
77 type = lib.types.int;
78 default = 5;
79 description = ''
80 Number of seconds to wait for the client to send a complete
81 request. Set to 0 to disable.
82 '';
83 };
84
85 responseTimeout = lib.mkOption {
86 type = lib.types.int;
87 default = 0;
88 description = ''
89 Number of seconds to wait for the client to send a complete
90 request and for stargazer to finish sending the response.
91 Set to 0 to disable.
92 '';
93 };
94
95 allowCgiUser = lib.mkOption {
96 type = lib.types.bool;
97 default = false;
98 description = ''
99 When enabled, the stargazer process will be given `CAP_SETGID`
100 and `CAP_SETUID` so that it can run cgi processes as a different
101 user. This is required if the `cgi-user` option is used for a route.
102 Note that these capabilities could allow privilege escalation so be
103 careful. For that reason, this is disabled by default.
104
105 You will need to create the user mentioned `cgi-user` if it does not
106 already exist.
107 '';
108 };
109
110 store = lib.mkOption {
111 type = lib.types.path;
112 default = /var/lib/gemini/certs;
113 description = ''
114 Path to the certificate store on disk. This should be a
115 persistent directory writable by Stargazer.
116 '';
117 };
118
119 certOrg = lib.mkOption {
120 type = lib.types.str;
121 default = "stargazer";
122 description = ''
123 The name of the organization responsible for the X.509
124 certificate's /O name.
125 '';
126 };
127
128 genCerts = lib.mkOption {
129 type = lib.types.bool;
130 default = true;
131 description = ''
132 Set to false to disable automatic certificate generation.
133 Use if you want to provide your own certs.
134 '';
135 };
136
137 regenCerts = lib.mkOption {
138 type = lib.types.bool;
139 default = true;
140 description = ''
141 Set to false to turn off automatic regeneration of expired certificates.
142 Use if you want to provide your own certs.
143 '';
144 };
145
146 certLifetime = lib.mkOption {
147 type = lib.types.str;
148 default = "";
149 description = ''
150 How long certs generated by Stargazer should live for.
151 Certs live forever by default.
152 '';
153 example = lib.literalExpression "\"1y\"";
154 };
155
156 debugMode = lib.mkOption {
157 type = lib.types.bool;
158 default = false;
159 description = "Run Stargazer in debug mode.";
160 };
161
162 routes = lib.mkOption {
163 type = lib.types.listOf (
164 lib.types.submodule {
165 freeformType =
166 with lib.types;
167 attrsOf (
168 nullOr (oneOf [
169 bool
170 int
171 float
172 str
173 ])
174 // {
175 description = "INI atom (null, bool, int, float or string)";
176 }
177 );
178 options.route = lib.mkOption {
179 type = lib.types.str;
180 description = "Route section name";
181 };
182 }
183 );
184 default = [ ];
185 description = ''
186 Routes that Stargazer should server.
187
188 Expressed as a list of attribute sets. Each set must have a key `route`
189 that becomes the section name for that route in the stargazer ini cofig.
190 The remaining keys and values become the parameters for that route.
191
192 [Refer to upstream docs for other params](https://git.sr.ht/~zethra/stargazer/tree/main/item/doc/stargazer.ini.5.txt)
193 '';
194 example = lib.literalExpression ''
195 [
196 {
197 route = "example.com";
198 root = "/srv/gemini/example.com"
199 }
200 {
201 route = "example.com:/man";
202 root = "/cgi-bin";
203 cgi = true;
204 }
205 {
206 route = "other.org~(.*)";
207 redirect = "gemini://example.com";
208 rewrite = "\1";
209 }
210 ]
211 '';
212 };
213
214 user = lib.mkOption {
215 type = lib.types.str;
216 default = "stargazer";
217 description = "User account under which stargazer runs.";
218 };
219
220 group = lib.mkOption {
221 type = lib.types.str;
222 default = "stargazer";
223 description = "Group account under which stargazer runs.";
224 };
225 };
226
227 config = lib.mkIf cfg.enable {
228 systemd.services.stargazer = {
229 description = "Stargazer gemini server";
230 after = [ "network.target" ];
231 wantedBy = [ "multi-user.target" ];
232 serviceConfig = {
233 ExecStart = "${pkgs.stargazer}/bin/stargazer ${configFile} ${lib.optionalString cfg.debugMode "-D"}";
234 Restart = "always";
235 # User and group
236 User = cfg.user;
237 Group = cfg.group;
238 AmbientCapabilities = lib.mkIf cfg.allowCgiUser [
239 "CAP_SETGID"
240 "CAP_SETUID"
241 ];
242
243 # Hardening
244 UMask = "0077";
245 PrivateTmp = true;
246 ProtectHome = true;
247 ProtectSystem = "full";
248 ProtectClock = true;
249 ProtectHostname = true;
250 ProtectControlGroups = true;
251 ProtectKernelLogs = true;
252 ProtectKernelModules = true;
253 ProtectKernelTunables = true;
254 ProtectProc = "invisible";
255 PrivateDevices = true;
256 NoNewPrivileges = true;
257 RestrictSUIDSGID = true;
258 PrivateMounts = true;
259 MemoryDenyWriteExecute = true;
260 LockPersonality = true;
261 RestrictRealtime = true;
262 RemoveIPC = true;
263 CapabilityBoundingSet =
264 [
265 "~CAP_SYS_PTRACE"
266 "~CAP_SYS_ADMIN"
267 "~CAP_SETPCAP"
268 "~CAP_SYS_TIME"
269 "~CAP_SYS_PACCT"
270 "~CAP_SYS_TTY_CONFIG "
271 "~CAP_SYS_CHROOT"
272 "~CAP_SYS_BOOT"
273 "~CAP_NET_ADMIN"
274 ]
275 ++ lib.lists.optional (!cfg.allowCgiUser) [
276 "~CAP_SETGID"
277 "~CAP_SETUID"
278 ];
279 SystemCallArchitectures = "native";
280 SystemCallFilter = [
281 "~@cpu-emulation @debug @keyring @mount @obsolete"
282 ] ++ lib.lists.optional (!cfg.allowCgiUser) [ "@privileged @setuid" ];
283 };
284 };
285
286 # Create default cert store
287 systemd.tmpfiles.rules = lib.mkIf (cfg.store == /var/lib/gemini/certs) [
288 ''d /var/lib/gemini/certs - "${cfg.user}" "${cfg.group}" -''
289 ];
290
291 users.users = lib.optionalAttrs (cfg.user == "stargazer") {
292 stargazer = {
293 group = cfg.group;
294 isSystemUser = true;
295 };
296 };
297
298 users.groups = lib.optionalAttrs (cfg.group == "stargazer") {
299 stargazer = { };
300 };
301 };
302
303 meta.maintainers = with lib.maintainers; [ gaykitty ];
304}