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 containing the attribute
104 <literal>_secret</literal> - a string pointing to a file
105 containing the value the option should be set to. See the
106 example in `services.glance.settings.pages` at the weather widget
107 with a location secret to get a better picture of this.
108 '';
109 };
110
111 openFirewall = mkOption {
112 type = types.bool;
113 default = false;
114 description = ''
115 Whether to open the firewall for Glance.
116 This adds `services.glance.settings.server.port` to `networking.firewall.allowedTCPPorts`.
117 '';
118 };
119 };
120
121 config = mkIf cfg.enable {
122 systemd.services.glance = {
123 description = "Glance feed dashboard server";
124 wantedBy = [ "multi-user.target" ];
125 after = [ "network.target" ];
126 path = [ pkgs.replace-secret ];
127
128 serviceConfig = {
129 ExecStartPre =
130 let
131 findSecrets =
132 data:
133 if isAttrs data then
134 if data ? _secret then
135 [ data ]
136 else
137 concatLists (map (attr: findSecrets (getAttr attr data)) (attrNames data))
138 else if isList data then
139 concatLists (map findSecrets data)
140 else
141 [ ];
142 secretPaths = catAttrs "_secret" (findSecrets cfg.settings);
143 mkSecretReplacement = secretPath: ''
144 replace-secret ${
145 lib.escapeShellArgs [
146 "_secret: ${secretPath}"
147 secretPath
148 mergedSettingsFile
149 ]
150 }
151 '';
152 secretReplacements = concatMapStrings mkSecretReplacement secretPaths;
153 in
154 # Use "+" to run as root because the secrets may not be accessible to glance
155 "+"
156 + pkgs.writeShellScript "glance-start-pre" ''
157 install -m 600 -o $USER ${settingsFile} ${mergedSettingsFile}
158 ${secretReplacements}
159 '';
160 ExecStart = "${getExe cfg.package} --config ${mergedSettingsFile}";
161 WorkingDirectory = "/var/lib/glance";
162 StateDirectory = "glance";
163 RuntimeDirectory = "glance";
164 RuntimeDirectoryMode = "0755";
165 PrivateTmp = true;
166 DynamicUser = true;
167 DevicePolicy = "closed";
168 LockPersonality = true;
169 MemoryDenyWriteExecute = true;
170 PrivateUsers = true;
171 ProtectHome = true;
172 ProtectHostname = true;
173 ProtectKernelLogs = true;
174 ProtectKernelModules = true;
175 ProtectKernelTunables = true;
176 ProtectControlGroups = true;
177 ProcSubset = "all";
178 RestrictNamespaces = true;
179 RestrictRealtime = true;
180 SystemCallArchitectures = "native";
181 UMask = "0077";
182 };
183 };
184
185 networking.firewall = mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.settings.server.port ]; };
186 };
187
188 meta.doc = ./glance.md;
189 meta.maintainers = [ lib.maintainers.drupol ];
190}