a more proper nixos module for the tangled knotserver
1# modified from https://tangled.sh/@tangled.sh/core/blob/master/flake.nix
2tangledFlake:
3{
4 config,
5 pkgs,
6 lib,
7 ...
8}:
9let
10 inherit (lib)
11 mkOption
12 mkEnableOption
13 types
14 mkIf
15 optional
16 ;
17 cfg = config.services.tangled-knotserver;
18 tangledPkgs = tangledFlake.packages.${pkgs.system};
19
20 keyfetch-wrapped =
21 pkgs.runCommandCC "tangled-packages-wrapped" { nativeBuildInputs = [ pkgs.makeBinaryWrapper ]; }
22 ''
23 mkdir -p $out/bin
24
25 makeBinaryWrapper ${lib.getExe' tangledPkgs.knot "knot"} $out/bin/keyfetch \
26 --add-flags "keys" \
27 --append-flags "-output authorized-keys" \
28 --append-flags "-internal-api=http://${cfg.server.internalListenAddr}" \
29 --append-flags "-git-dir=${cfg.repo.scanPath}" \
30 --append-flags "-log-path=/var/log/knotserver/repoguard.log"
31 '';
32
33in
34{
35 imports = [
36 (lib.mkRenamedOptionModule ["services" "tangled-knotserver" "gitUser"] ["services" "tangled-knotserver" "user"])
37 ];
38
39 options = {
40 services.tangled-knotserver = {
41 enable = mkEnableOption "a tangled knot server";
42
43 openFirewall = mkOption {
44 type = types.bool;
45 default = false;
46 description = "Whether to automatically configure the firewall to open necessary ports.";
47 };
48
49 appviewEndpoint = mkOption {
50 type = types.str;
51 default = "https://tangled.sh";
52 description = "Appview endpoint";
53 };
54
55 user = mkOption {
56 type = types.str;
57 default = "git";
58 description = "User that runs the server, hosts git repos and performs git operations";
59 };
60
61 git = {
62 name = mkOption {
63 type = types.str;
64 default = "Tangled Knot daemon";
65 description = "Git username for git operations that requires one.";
66 };
67 email = mkOption {
68 type = types.str;
69 default = "knot@example.invalid";
70 description = "Git email address for git operations that requires one.";
71 };
72 };
73
74 # TODO: should a `stateDirectory` option be added?
75
76 repo = {
77 scanPath = mkOption {
78 type = types.path;
79 default = "/var/lib/tangled-knot";
80 description = "Path where repositories are stored";
81 };
82
83 mainBranch = mkOption {
84 type = types.str;
85 default = "main";
86 description = "Default branch name for repositories";
87 };
88 };
89
90 server = {
91 listenAddr = mkOption {
92 type = types.str;
93 default = "0.0.0.0:5555";
94 description = "Address to listen on";
95 };
96
97 internalListenAddr = mkOption {
98 type = types.str;
99 default = "127.0.0.1:5444";
100 description = "Internal address for inter-service communication";
101 };
102
103 dbPath = mkOption {
104 type = types.path;
105 default = "knotserver.db";
106 description = "Path to the database file";
107 };
108
109 hostname = mkOption {
110 type = types.str;
111 example = "knot.tangled.sh";
112 description = "Hostname for the server (required)";
113 };
114
115 dev = mkOption {
116 type = types.bool;
117 default = false;
118 description = "Enable development mode (disables signature verification)";
119 internal = true;
120 };
121 };
122
123 extraConfig = mkOption {
124 type = types.attrsOf types.str;
125 default = { };
126 example = lib.literalExpression ''
127 {
128 KNOT_SERVER_OWNER = "did:web:handle.invalid";
129 }
130 '';
131 description = ''
132 Additional environment variables. Use `environmentFile` for secrets.
133
134 `KNOT_SERVER_OWNER` must be set for the program to work correctly.
135 '';
136 };
137
138 extraSshdConfig = mkOption {
139 type = types.lines;
140 default = "";
141 example = ''
142 Banner none
143 PasswordAuthentication no
144 KbdInteractiveAuthentication no
145 '';
146 description = "Additional sshd_config options to set for the git user.";
147 };
148
149 environmentFile = mkOption {
150 type = types.nullOr types.path;
151 default = null;
152 example = "/etc/tangled/knotserver.env";
153 description = ''
154 Environment file to set additional configuration and secrets for the knotserver.
155 '';
156 };
157 };
158 };
159
160 config = mkIf cfg.enable {
161 warnings = optional cfg.server.dev ''
162 tangled-knotserver: development mode is enabled. This is not recommended in production as signature checks are disabled.
163 '';
164
165 assertions = [
166 {
167 assertion = tangledPkgs ? knot;
168 message = "tangled-knotserver: your version of tangled flake is not compatible with this version of the knotserver module. please consider updating the pinned @tangled.sh/core version.";
169 }
170 ];
171
172 environment.systemPackages = with pkgs; [ git ];
173
174 users.users.${cfg.user} = {
175 createHome = true;
176 home = cfg.repo.scanPath;
177 group = cfg.user;
178 isSystemUser = true;
179 useDefaultShell = true;
180 };
181
182 users.groups.${cfg.user} = { };
183
184 systemd.services.knotserver = {
185 description = "knotserver service";
186 path = [pkgs.git];
187 after = [
188 "network-online.target"
189 "sshd.service"
190 ];
191 wants = [
192 "network-online.target"
193 "sshd.service"
194 ];
195 wantedBy = [ "multi-user.target" ];
196
197 preStart = ''
198 git config --global user.name "${cfg.git.name}"
199 git config --global user.email "${cfg.git.email}"
200 '';
201
202 serviceConfig = {
203 User = cfg.user;
204 WorkingDirectory = cfg.repo.scanPath;
205 ExecStart = if (tangledPkgs ? knotserver) # compat
206 then lib.getExe' tangledPkgs.knotserver "knotserver"
207 else "${lib.getExe' tangledPkgs.knot "knot"} server";
208 Restart = "always";
209
210 StateDirectory = mkIf (lib.hasPrefix "/var/lib/tangled-knot" cfg.repo.scanPath) "tangled-knot";
211 EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
212 # TODO: hardening
213 };
214
215 environment = {
216 APPVIEW_ENDPOINT = cfg.appviewEndpoint;
217 KNOT_REPO_SCAN_PATH = cfg.repo.scanPath;
218 KNOT_SERVER_INTERNAL_LISTEN_ADDR = cfg.server.internalListenAddr;
219 KNOT_SERVER_LISTEN_ADDR = cfg.server.listenAddr;
220 KNOT_SERVER_HOSTNAME = cfg.server.hostname;
221 } // cfg.extraConfig;
222 };
223
224 systemd.tmpfiles.settings."knotserver-settings"."/var/log/knotserver"."d" = {
225 mode = "0750";
226 user = config.users.users.${cfg.user}.name;
227 group = config.users.groups.${cfg.user}.name;
228 };
229
230 services.openssh = {
231 enable = true; # required for the module to actually function
232 extraConfig = ''
233 Match User ${cfg.user}
234 AuthorizedKeysCommand ${config.security.wrapperDir}/keyfetch
235 AuthorizedKeysCommandUser nobody
236 ${cfg.extraSshdConfig}
237 '';
238 };
239
240 # get around openssh restrictions
241 security.wrappers.keyfetch = {
242 owner = "root";
243 group = config.users.groups.${cfg.user}.name;
244 permissions = "u+rx,go+x";
245 source = lib.getExe' keyfetch-wrapped "keyfetch";
246 };
247
248 # open firewall ports if configured
249 networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [22];
250 };
251}