1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.glance;
9
10 inherit (lib)
11 catAttrs
12 concatMapStrings
13 getExe
14 mkEnableOption
15 mkIf
16 mkOption
17 mkPackageOption
18 types
19 ;
20
21 inherit (builtins)
22 concatLists
23 isAttrs
24 isList
25 attrNames
26 getAttr
27 ;
28
29 settingsFormat = pkgs.formats.yaml { };
30 settingsFile = settingsFormat.generate "glance.yaml" cfg.settings;
31 mergedSettingsFile = "/run/glance/glance.yaml";
32in
33{
34 options.services.glance = {
35 enable = mkEnableOption "glance";
36 package = mkPackageOption pkgs "glance" { };
37
38 settings = mkOption {
39 type = types.submodule {
40 freeformType = settingsFormat.type;
41 options = {
42 server = {
43 host = mkOption {
44 description = "Glance bind address";
45 default = "127.0.0.1";
46 example = "0.0.0.0";
47 type = types.str;
48 };
49 port = mkOption {
50 description = "Glance port to listen on";
51 default = 8080;
52 example = 5678;
53 type = types.port;
54 };
55 };
56 pages = mkOption {
57 type = settingsFormat.type;
58 description = ''
59 List of pages to be present on the dashboard.
60
61 See <https://github.com/glanceapp/glance/blob/main/docs/configuration.md#pages--columns>
62 '';
63 default = [
64 {
65 name = "Calendar";
66 columns = [
67 {
68 size = "full";
69 widgets = [ { type = "calendar"; } ];
70 }
71 ];
72 }
73 ];
74 example = [
75 {
76 name = "Home";
77 columns = [
78 {
79 size = "full";
80 widgets = [
81 { type = "calendar"; }
82 {
83 type = "weather";
84 location = {
85 _secret = "/var/lib/secrets/glance/location";
86 };
87 }
88 ];
89 }
90 ];
91 }
92 ];
93 };
94 };
95 };
96 default = { };
97 description = ''
98 Configuration written to a yaml file that is read by glance. See
99 <https://github.com/glanceapp/glance/blob/main/docs/configuration.md>
100 for more.
101
102 Settings containing secret data should be set to an
103 attribute set with this format: `{ _secret = "/path/to/secret"; }`.
104 See the example in `services.glance.settings.pages` at the weather widget
105 with a location secret to get a better picture of this.
106
107 Alternatively, you can use a single file with environment variables,
108 see `services.glance.environmentFile`.
109 '';
110 };
111
112 environmentFile = mkOption {
113 type = types.nullOr types.path;
114 description =
115 let
116 singleQuotes = "''";
117 in
118 ''
119 Path to an environment file as defined in {manpage}`systemd.exec(5)`.
120
121 See upstream documentation
122 <https://github.com/glanceapp/glance/blob/main/docs/configuration.md#environment-variables>.
123
124 Example content of the file:
125 ```
126 TIMEZONE=Europe/Paris
127 ```
128
129 Example `services.glance.settings.pages` configuration:
130 ```nix
131 [
132 {
133 name = "Home";
134 columns = [
135 {
136 size = "full";
137 widgets = [
138 {
139 type = "clock";
140 timezone = "\''${TIMEZONE}";
141 label = "Local Time";
142 }
143 ];
144 }
145 ];
146 }
147 ];
148 ```
149
150 Note that when using Glance's `''${ENV_VAR}` syntax in Nix,
151 you need to escape it as follows: use `\''${ENV_VAR}` in `"` strings
152 and `${singleQuotes}''${ENV_VAR}` in `${singleQuotes}` strings.
153
154 Alternatively, you can put each secret in it's own file,
155 see `services.glance.settings`.
156 '';
157 default = "/dev/null";
158 example = "/var/lib/secrets/glance";
159 };
160
161 openFirewall = mkOption {
162 type = types.bool;
163 default = false;
164 description = ''
165 Whether to open the firewall for Glance.
166 This adds `services.glance.settings.server.port` to `networking.firewall.allowedTCPPorts`.
167 '';
168 };
169 };
170
171 config = mkIf cfg.enable {
172 systemd.services.glance = {
173 description = "Glance feed dashboard server";
174 wantedBy = [ "multi-user.target" ];
175 after = [ "network.target" ];
176 path = [ pkgs.replace-secret ];
177
178 serviceConfig = {
179 ExecStartPre =
180 let
181 findSecrets =
182 data:
183 if isAttrs data then
184 if data ? _secret then
185 [ data ]
186 else
187 concatLists (map (attr: findSecrets (getAttr attr data)) (attrNames data))
188 else if isList data then
189 concatLists (map findSecrets data)
190 else
191 [ ];
192 secretPaths = catAttrs "_secret" (findSecrets cfg.settings);
193 mkSecretReplacement = secretPath: ''
194 replace-secret ${
195 lib.escapeShellArgs [
196 "_secret: ${secretPath}"
197 secretPath
198 mergedSettingsFile
199 ]
200 }
201 '';
202 secretReplacements = concatMapStrings mkSecretReplacement secretPaths;
203 in
204 # Use "+" to run as root because the secrets may not be accessible to glance
205 "+"
206 + pkgs.writeShellScript "glance-start-pre" ''
207 install -m 600 -o $USER ${settingsFile} ${mergedSettingsFile}
208 ${secretReplacements}
209 '';
210 ExecStart = "${getExe cfg.package} --config ${mergedSettingsFile}";
211 WorkingDirectory = "/var/lib/glance";
212 EnvironmentFile = cfg.environmentFile;
213 StateDirectory = "glance";
214 RuntimeDirectory = "glance";
215 RuntimeDirectoryMode = "0755";
216 PrivateTmp = true;
217 DynamicUser = true;
218 DevicePolicy = "closed";
219 LockPersonality = true;
220 MemoryDenyWriteExecute = true;
221 PrivateUsers = true;
222 ProtectHome = true;
223 ProtectHostname = true;
224 ProtectKernelLogs = true;
225 ProtectKernelModules = true;
226 ProtectKernelTunables = true;
227 ProtectControlGroups = true;
228 ProcSubset = "all";
229 RestrictNamespaces = true;
230 RestrictRealtime = true;
231 SystemCallArchitectures = "native";
232 UMask = "0077";
233 };
234 };
235
236 networking.firewall = mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.settings.server.port ]; };
237 };
238
239 meta.doc = ./glance.md;
240 meta.maintainers = [ ];
241}