1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8
9 cfg = config.services.froide-govplan;
10 pythonFmt = pkgs.formats.pythonVars { };
11 settingsFile = pythonFmt.generate "extra_settings.py" cfg.settings;
12
13 pkg = cfg.package.overridePythonAttrs (old: {
14 postInstall =
15 old.postInstall
16 + ''
17 ln -s ${settingsFile} $out/${pkg.python.sitePackages}/froide_govplan/project/extra_settings.py
18 '';
19 });
20
21 froide-govplan = pkgs.writeShellApplication {
22 name = "froide-govplan";
23 runtimeInputs = [ pkgs.coreutils ];
24 text = ''
25 SUDO="exec"
26 if [[ "$USER" != govplan ]]; then
27 SUDO="exec /run/wrappers/bin/sudo -u govplan"
28 fi
29 $SUDO env ${lib.getExe pkg} "$@"
30 '';
31 };
32
33 # Service hardening
34 defaultServiceConfig = {
35 # Secure the services
36 ReadWritePaths = [ cfg.dataDir ];
37 CacheDirectory = "froide-govplan";
38 CapabilityBoundingSet = "";
39 # ProtectClock adds DeviceAllow=char-rtc r
40 DeviceAllow = "";
41 LockPersonality = true;
42 MemoryDenyWriteExecute = true;
43 NoNewPrivileges = true;
44 PrivateDevices = true;
45 PrivateMounts = true;
46 PrivateTmp = true;
47 PrivateUsers = true;
48 ProtectClock = true;
49 ProtectHome = true;
50 ProtectHostname = true;
51 ProtectSystem = "strict";
52 ProtectControlGroups = true;
53 ProtectKernelLogs = true;
54 ProtectKernelModules = true;
55 ProtectKernelTunables = true;
56 ProtectProc = "invisible";
57 ProcSubset = "pid";
58 RestrictAddressFamilies = [
59 "AF_UNIX"
60 "AF_INET"
61 "AF_INET6"
62 ];
63 RestrictNamespaces = true;
64 RestrictRealtime = true;
65 RestrictSUIDSGID = true;
66 SystemCallArchitectures = "native";
67 SystemCallFilter = [
68 "@system-service"
69 "~@privileged @setuid @keyring"
70 ];
71 UMask = "0066";
72 };
73
74in
75{
76 options.services.froide-govplan = {
77
78 enable = lib.mkEnableOption "Gouvernment planer web app Govplan";
79
80 package = lib.mkPackageOption pkgs "froide-govplan" { };
81
82 hostName = lib.mkOption {
83 type = lib.types.str;
84 default = "localhost";
85 description = "FQDN for the froide-govplan instance.";
86 };
87
88 dataDir = lib.mkOption {
89 type = lib.types.str;
90 default = "/var/lib/froide-govplan";
91 description = "Directory to store the Froide-Govplan server data.";
92 };
93
94 secretKeyFile = lib.mkOption {
95 type = lib.types.nullOr lib.types.path;
96 default = null;
97 description = ''
98 Path to a file containing the secret key.
99 '';
100 };
101
102 settings = lib.mkOption {
103 description = ''
104 Configuration options to set in `extra_settings.py`.
105 '';
106
107 default = { };
108
109 type = lib.types.submodule {
110 freeformType = pythonFmt.type;
111
112 options = {
113 ALLOWED_HOSTS = lib.mkOption {
114 type = with lib.types; listOf str;
115 default = [ "*" ];
116 description = ''
117 A list of valid fully-qualified domain names (FQDNs) and/or IP
118 addresses that can be used to reach the Froide-Govplan service.
119 '';
120 };
121 };
122 };
123 };
124
125 };
126
127 config = lib.mkIf cfg.enable {
128
129 services.froide-govplan = {
130 settings = {
131 STATIC_ROOT = "${cfg.dataDir}/static";
132 DEBUG = false;
133 DATABASES.default = {
134 ENGINE = "django.contrib.gis.db.backends.postgis";
135 NAME = "govplan";
136 USER = "govplan";
137 HOST = "/run/postgresql";
138 };
139 };
140 };
141
142 services.postgresql = {
143 enable = true;
144 ensureDatabases = [ "govplan" ];
145 ensureUsers = [
146 {
147 name = "govplan";
148 ensureDBOwnership = true;
149 }
150 ];
151 extensions = ps: with ps; [ postgis ];
152 };
153
154 services.nginx = {
155 enable = lib.mkDefault true;
156 virtualHosts."${cfg.hostName}".locations = {
157 "/".extraConfig = "proxy_pass http://unix:/run/froide-govplan/froide-govplan.socket;";
158 "/static/".alias = "${cfg.dataDir}/static/";
159 };
160 proxyTimeout = lib.mkDefault "120s";
161 };
162
163 systemd = {
164 services = {
165
166 postgresql.serviceConfig.ExecStartPost =
167 let
168 sqlFile = pkgs.writeText "immich-pgvectors-setup.sql" ''
169 CREATE EXTENSION IF NOT EXISTS postgis;
170 '';
171 in
172 [
173 ''
174 ${lib.getExe' config.services.postgresql.package "psql"} -d govplan -f "${sqlFile}"
175 ''
176 ];
177
178 froide-govplan = {
179 description = "Gouvernment planer Govplan";
180 serviceConfig = defaultServiceConfig // {
181 WorkingDirectory = cfg.dataDir;
182 StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/froide-govplan") "froide-govplan";
183 User = "govplan";
184 Group = "govplan";
185 };
186 after = [
187 "postgresql.service"
188 "network.target"
189 "systemd-tmpfiles-setup.service"
190 ];
191 wantedBy = [ "multi-user.target" ];
192 environment =
193 {
194 PYTHONPATH = pkg.pythonPath;
195 GDAL_LIBRARY_PATH = "${pkgs.gdal}/lib/libgdal.so";
196 GEOS_LIBRARY_PATH = "${pkgs.geos}/lib/libgeos_c.so";
197 }
198 // lib.optionalAttrs (cfg.secretKeyFile != null) {
199 SECRET_KEY_FILE = cfg.secretKeyFile;
200 };
201 preStart = ''
202 # Auto-migrate on first run or if the package has changed
203 versionFile="${cfg.dataDir}/src-version"
204 version=$(cat "$versionFile" 2>/dev/null || echo 0)
205
206 if [[ $version != ${pkg.version} ]]; then
207 ${lib.getExe pkg} migrate --no-input
208 ${lib.getExe pkg} collectstatic --no-input --clear
209 echo ${pkg.version} > "$versionFile"
210 fi
211 '';
212 script = ''
213 ${pkg.python.pkgs.uvicorn}/bin/uvicorn --uds /run/froide-govplan/froide-govplan.socket \
214 --app-dir ${pkg}/${pkg.python.sitePackages}/froide_govplan \
215 project.asgi:application
216 '';
217 };
218 };
219
220 };
221
222 systemd.tmpfiles.rules = [ "d /run/froide-govplan - govplan govplan - -" ];
223
224 environment.systemPackages = [ froide-govplan ];
225
226 users.users.govplan = {
227 home = "${cfg.dataDir}";
228 isSystemUser = true;
229 group = "govplan";
230 };
231 users.groups.govplan = { };
232
233 };
234
235 meta.maintainers = with lib.maintainers; [ onny ];
236
237}