1{
2 config,
3 lib,
4 pkgs,
5 utils,
6 ...
7}:
8let
9 inherit (lib)
10 getExe
11 mkDefault
12 mkEnableOption
13 mkIf
14 mkMerge
15 mkOption
16 mkPackageOption
17 optional
18 types
19 ;
20
21 cfgApi = config.services.ente.api;
22 cfgWeb = config.services.ente.web;
23
24 webPackage =
25 enteApp:
26 cfgWeb.package.override {
27 inherit enteApp;
28 enteMainUrl = "https://${cfgWeb.domains.photos}";
29 extraBuildEnv = {
30 NEXT_PUBLIC_ENTE_ENDPOINT = "https://${cfgWeb.domains.api}";
31 NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT = "https://${cfgWeb.domains.albums}";
32 NEXT_TELEMETRY_DISABLED = "1";
33 };
34 };
35
36 defaultUser = "ente";
37 defaultGroup = "ente";
38 dataDir = "/var/lib/ente";
39
40 yamlFormat = pkgs.formats.yaml { };
41in
42{
43 options.services.ente = {
44 web = {
45 enable = mkEnableOption "Ente web frontend (Photos, Albums)";
46 package = mkPackageOption pkgs "ente-web" { };
47
48 domains = {
49 api = mkOption {
50 type = types.str;
51 example = "api.ente.example.com";
52 description = ''
53 The domain under which the api is served. This will NOT serve the api itself,
54 but is a required setting to host the frontends! This will automatically be set
55 for you if you enable both the api server and web frontends.
56 '';
57 };
58
59 accounts = mkOption {
60 type = types.str;
61 example = "accounts.ente.example.com";
62 description = "The domain under which the accounts frontend will be served.";
63 };
64
65 cast = mkOption {
66 type = types.str;
67 example = "cast.ente.example.com";
68 description = "The domain under which the cast frontend will be served.";
69 };
70
71 albums = mkOption {
72 type = types.str;
73 example = "albums.ente.example.com";
74 description = "The domain under which the albums frontend will be served.";
75 };
76
77 photos = mkOption {
78 type = types.str;
79 example = "photos.ente.example.com";
80 description = "The domain under which the photos frontend will be served.";
81 };
82 };
83 };
84
85 api = {
86 enable = mkEnableOption "Museum (API server for ente.io)";
87 package = mkPackageOption pkgs "museum" { };
88 nginx.enable = mkEnableOption "nginx proxy for the API server";
89
90 user = mkOption {
91 type = types.str;
92 default = defaultUser;
93 description = "User under which museum runs. If you set this option you must make sure the user exists.";
94 };
95
96 group = mkOption {
97 type = types.str;
98 default = defaultGroup;
99 description = "Group under which museum runs. If you set this option you must make sure the group exists.";
100 };
101
102 domain = mkOption {
103 type = types.str;
104 example = "api.ente.example.com";
105 description = "The domain under which the api will be served.";
106 };
107
108 enableLocalDB = mkEnableOption "the automatic creation of a local postgres database for museum.";
109
110 settings = mkOption {
111 description = ''
112 Museum yaml configuration. Refer to upstream [local.yaml](https://github.com/ente-io/ente/blob/main/server/configurations/local.yaml) for more information.
113 You can specify secret values in this configuration by setting `somevalue._secret = "/path/to/file"` instead of setting `somevalue` directly.
114 '';
115 default = { };
116 type = types.submodule {
117 freeformType = yamlFormat.type;
118 options = {
119 apps = {
120 public-albums = mkOption {
121 type = types.str;
122 default = "https://albums.ente.io";
123 description = ''
124 If you're running a self hosted instance and wish to serve public links,
125 set this to the URL where your albums web app is running.
126 '';
127 };
128
129 cast = mkOption {
130 type = types.str;
131 default = "https://cast.ente.io";
132 description = ''
133 Set this to the URL where your cast page is running.
134 This is for browser and chromecast casting support.
135 '';
136 };
137
138 accounts = mkOption {
139 type = types.str;
140 default = "https://accounts.ente.io";
141 description = ''
142 Set this to the URL where your accounts page is running.
143 This is primarily for passkey support.
144 '';
145 };
146 };
147
148 db = {
149 host = mkOption {
150 type = types.str;
151 description = "The database host";
152 };
153
154 port = mkOption {
155 type = types.port;
156 default = 5432;
157 description = "The database port";
158 };
159
160 name = mkOption {
161 type = types.str;
162 description = "The database name";
163 };
164
165 user = mkOption {
166 type = types.str;
167 description = "The database user";
168 };
169 };
170 };
171 };
172 };
173 };
174 };
175
176 config = mkMerge [
177 (mkIf cfgApi.enable {
178 services.postgresql = mkIf cfgApi.enableLocalDB {
179 enable = true;
180 ensureUsers = [
181 {
182 name = "ente";
183 ensureDBOwnership = true;
184 }
185 ];
186 ensureDatabases = [ "ente" ];
187 };
188
189 services.ente.web.domains.api = mkIf cfgWeb.enable cfgApi.domain;
190 services.ente.api.settings = {
191 # This will cause logs to be written to stdout/err, which then end up in the journal
192 log-file = mkDefault "";
193 db = mkIf cfgApi.enableLocalDB {
194 host = "/run/postgresql";
195 port = 5432;
196 name = "ente";
197 user = "ente";
198 };
199 };
200
201 systemd.services.ente = {
202 description = "Ente.io Museum API Server";
203 after = [ "network.target" ] ++ optional cfgApi.enableLocalDB "postgresql.service";
204 requires = optional cfgApi.enableLocalDB "postgresql.service";
205 wantedBy = [ "multi-user.target" ];
206
207 preStart = ''
208 # Generate config including secret values. YAML is a superset of JSON, so we can use this here.
209 ${utils.genJqSecretsReplacementSnippet cfgApi.settings "/run/ente/local.yaml"}
210
211 # Setup paths
212 mkdir -p ${dataDir}/configurations
213 ln -sTf /run/ente/local.yaml ${dataDir}/configurations/local.yaml
214 '';
215
216 serviceConfig = {
217 ExecStart = getExe cfgApi.package;
218 Type = "simple";
219 Restart = "on-failure";
220
221 AmbientCapabilities = [ ];
222 CapabilityBoundingSet = [ ];
223 LockPersonality = true;
224 MemoryDenyWriteExecute = true;
225 NoNewPrivileges = true;
226 PrivateMounts = true;
227 PrivateTmp = true;
228 PrivateUsers = false;
229 ProcSubset = "pid";
230 ProtectClock = true;
231 ProtectControlGroups = true;
232 ProtectHome = true;
233 ProtectHostname = true;
234 ProtectKernelLogs = true;
235 ProtectKernelModules = true;
236 ProtectKernelTunables = true;
237 ProtectProc = "invisible";
238 ProtectSystem = "strict";
239 RestrictAddressFamilies = [
240 "AF_INET"
241 "AF_INET6"
242 "AF_NETLINK"
243 "AF_UNIX"
244 ];
245 RestrictNamespaces = true;
246 RestrictRealtime = true;
247 RestrictSUIDSGID = true;
248 SystemCallArchitectures = "native";
249 SystemCallFilter = "@system-service";
250 UMask = "077";
251
252 BindReadOnlyPaths = [
253 "${cfgApi.package}/share/museum/migrations:${dataDir}/migrations"
254 "${cfgApi.package}/share/museum/mail-templates:${dataDir}/mail-templates"
255 "${cfgApi.package}/share/museum/web-templates:${dataDir}/web-templates"
256 ];
257
258 User = cfgApi.user;
259 Group = cfgApi.group;
260
261 SyslogIdentifier = "ente";
262 StateDirectory = "ente";
263 WorkingDirectory = dataDir;
264 RuntimeDirectory = "ente";
265 };
266
267 # Environment MUST be called local, otherwise we cannot log to stdout
268 environment = {
269 ENVIRONMENT = "local";
270 GIN_MODE = "release";
271 };
272 };
273
274 users = {
275 users = mkIf (cfgApi.user == defaultUser) {
276 ${defaultUser} = {
277 description = "ente.io museum service user";
278 inherit (cfgApi) group;
279 isSystemUser = true;
280 home = dataDir;
281 };
282 };
283 groups = mkIf (cfgApi.group == defaultGroup) { ${defaultGroup} = { }; };
284 };
285
286 services.nginx = mkIf cfgApi.nginx.enable {
287 enable = true;
288 upstreams.museum = {
289 servers."localhost:8080" = { };
290 extraConfig = ''
291 zone museum 64k;
292 keepalive 20;
293 '';
294 };
295
296 virtualHosts.${cfgApi.domain} = {
297 forceSSL = mkDefault true;
298 locations."/".proxyPass = "http://museum";
299 extraConfig = ''
300 client_max_body_size 4M;
301 '';
302 };
303 };
304 })
305 (mkIf cfgWeb.enable {
306 services.ente.api.settings = mkIf cfgApi.enable {
307 apps = {
308 accounts = "https://${cfgWeb.domains.accounts}";
309 cast = "https://${cfgWeb.domains.cast}";
310 public-albums = "https://${cfgWeb.domains.albums}";
311 };
312
313 webauthn = {
314 rpid = cfgWeb.domains.accounts;
315 rporigins = [ "https://${cfgWeb.domains.accounts}" ];
316 };
317 };
318
319 services.nginx =
320 let
321 domainFor = app: cfgWeb.domains.${app};
322 in
323 {
324 enable = true;
325 virtualHosts.${domainFor "accounts"} = {
326 forceSSL = mkDefault true;
327 locations."/" = {
328 root = webPackage "accounts";
329 tryFiles = "$uri $uri.html /index.html";
330 extraConfig = ''
331 add_header Access-Control-Allow-Origin 'https://${cfgWeb.domains.api}';
332 '';
333 };
334 };
335 virtualHosts.${domainFor "cast"} = {
336 forceSSL = mkDefault true;
337 locations."/" = {
338 root = webPackage "cast";
339 tryFiles = "$uri $uri.html /index.html";
340 extraConfig = ''
341 add_header Access-Control-Allow-Origin 'https://${cfgWeb.domains.api}';
342 '';
343 };
344 };
345 virtualHosts.${domainFor "photos"} = {
346 serverAliases = [
347 (domainFor "albums") # the albums app is shared with the photos frontend
348 ];
349 forceSSL = mkDefault true;
350 locations."/" = {
351 root = webPackage "photos";
352 tryFiles = "$uri $uri.html /index.html";
353 extraConfig = ''
354 add_header Access-Control-Allow-Origin 'https://${cfgWeb.domains.api}';
355 '';
356 };
357 };
358 };
359 })
360 ];
361
362 meta.maintainers = with lib.maintainers; [ oddlama ];
363}