1{
2 config,
3 pkgs,
4 lib,
5 ...
6}: let
7 cfg = config.services.tangled-knot;
8in
9 with lib; {
10 options = {
11 services.tangled-knot = {
12 enable = mkOption {
13 type = types.bool;
14 default = false;
15 description = "Enable a tangled knot";
16 };
17
18 package = mkOption {
19 type = types.package;
20 description = "Package to use for the knot";
21 };
22
23 appviewEndpoint = mkOption {
24 type = types.str;
25 default = "https://tangled.sh";
26 description = "Appview endpoint";
27 };
28
29 gitUser = mkOption {
30 type = types.str;
31 default = "git";
32 description = "User that hosts git repos and performs git operations";
33 };
34
35 openFirewall = mkOption {
36 type = types.bool;
37 default = true;
38 description = "Open port 22 in the firewall for ssh";
39 };
40
41 stateDir = mkOption {
42 type = types.path;
43 default = "/home/${cfg.gitUser}";
44 description = "Tangled knot data directory";
45 };
46
47 repo = {
48 scanPath = mkOption {
49 type = types.path;
50 default = cfg.stateDir;
51 description = "Path where repositories are scanned from";
52 };
53
54 mainBranch = mkOption {
55 type = types.str;
56 default = "main";
57 description = "Default branch name for repositories";
58 };
59 };
60
61 server = {
62 listenAddr = mkOption {
63 type = types.str;
64 default = "0.0.0.0:5555";
65 description = "Address to listen on";
66 };
67
68 internalListenAddr = mkOption {
69 type = types.str;
70 default = "127.0.0.1:5444";
71 description = "Internal address for inter-service communication";
72 };
73
74 secretFile = mkOption {
75 type = lib.types.path;
76 example = "KNOT_SERVER_SECRET=<hash>";
77 description = "File containing secret key provided by appview (required)";
78 };
79
80 dbPath = mkOption {
81 type = types.path;
82 default = "${cfg.stateDir}/knotserver.db";
83 description = "Path to the database file";
84 };
85
86 hostname = mkOption {
87 type = types.str;
88 example = "knot.tangled.sh";
89 description = "Hostname for the server (required)";
90 };
91
92 dev = mkOption {
93 type = types.bool;
94 default = false;
95 description = "Enable development mode (disables signature verification)";
96 };
97 };
98 };
99 };
100
101 config = mkIf cfg.enable {
102 environment.systemPackages = [
103 pkgs.git
104 cfg.package
105 ];
106
107 system.activationScripts.gitConfig = ''
108 mkdir -p "${cfg.repo.scanPath}"
109 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
110
111 mkdir -p "${cfg.stateDir}/.config/git"
112 cat > "${cfg.stateDir}/.config/git/config" << EOF
113 [user]
114 name = Git User
115 email = git@example.com
116 [receive]
117 advertisePushOptions = true
118 EOF
119 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
120 '';
121
122 users.users.${cfg.gitUser} = {
123 isSystemUser = true;
124 useDefaultShell = true;
125 home = cfg.stateDir;
126 createHome = true;
127 group = cfg.gitUser;
128 };
129
130 users.groups.${cfg.gitUser} = {};
131
132 services.openssh = {
133 enable = true;
134 extraConfig = ''
135 Match User ${cfg.gitUser}
136 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper
137 AuthorizedKeysCommandUser nobody
138 '';
139 };
140
141 environment.etc."ssh/keyfetch_wrapper" = {
142 mode = "0555";
143 text = ''
144 #!${pkgs.stdenv.shell}
145 ${cfg.package}/bin/knot keys \
146 -output authorized-keys \
147 -internal-api "http://${cfg.server.internalListenAddr}" \
148 -git-dir "${cfg.repo.scanPath}" \
149 -log-path /tmp/knotguard.log
150 '';
151 };
152
153 systemd.services.knot = {
154 description = "knot service";
155 after = ["network.target" "sshd.service"];
156 wantedBy = ["multi-user.target"];
157 serviceConfig = {
158 User = cfg.gitUser;
159 WorkingDirectory = cfg.stateDir;
160 Environment = [
161 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
162 "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
163 "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
164 "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
165 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
166 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
167 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
168 ];
169 EnvironmentFile = cfg.server.secretFile;
170 ExecStart = "${cfg.package}/bin/knot server";
171 Restart = "always";
172 };
173 };
174
175 networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [22];
176 };
177 }