This repository has no description
5.9 kB
185 lines
1{
2 config,
3 lib,
4 pkgs,
5 self,
6 ...
7}:
8let
9 # The one and only calendar, lass/calendar: own events (phone via DAVx⁵,
10 # calendar.lassul.us), booking requests and bookings mirrored by tools/book
11 # (configs/book.nix), and whatever Hermes adds. The rights below make it the
12 # only writable collection: nobody can create, delete or replace calendars.
13 calendar = "lass/calendar";
14
15 # lass: phone, web client and tools/book.
16 # hermes: the agent on coaxmetal (machines/coaxmetal/hermes.nix). Its var is
17 # shared (and declared there too) so coaxmetal's hermes-env can read the
18 # password without radicale running there.
19 users = [
20 "lass"
21 "hermes"
22 ];
23 sharedUsers = [ "hermes" ];
24
25 # One var per user with a random password.
26 userGenerators = lib.listToAttrs (
27 map (user: {
28 name = "radicale-${user}";
29 value = import ./radicale-user-generator.nix pkgs user // {
30 share = lib.elem user sharedUsers;
31 };
32 }) users
33 );
34
35 loadCredentials = map (
36 user:
37 "radicale-${user}-htpasswd:${
38 config.clan.core.vars.generators."radicale-${user}".files."htpasswd-line".path
39 }"
40 ) users;
41
42 assembleHtpasswd = pkgs.writeShellScript "radicale-htpasswd" (
43 lib.concatMapStringsSep "\n" (user: "cat \${CREDENTIALS_DIRECTORY}/radicale-${user}-htpasswd") users
44 );
45
46 # Calino (static, local-first CalDAV web client) on its own origin; accounts
47 # and settings stay in the browser, so nothing is stored server-side. Log in
48 # with server https://cal.lassul.us/ and a radicale user.
49 webClient = "calendar.lassul.us";
50 calino = self.packages.${pkgs.stdenv.hostPlatform.system}.calino;
51
52 proxyHeaders = ''
53 proxy_set_header X-Script-Name /;
54 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
55 proxy_set_header Host $host;
56 '';
57
58 # CORS for the web client; see the maps in appendHttpConfig below.
59 cors = ''
60 add_header Access-Control-Allow-Origin $radicale_cors_origin always;
61 add_header Access-Control-Allow-Methods "GET, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, OPTIONS, MKCOL, MKCALENDAR, MOVE" always;
62 add_header Access-Control-Allow-Headers "Authorization, Content-Type, Depth, Prefer, If-Match, If-None-Match" always;
63 add_header Access-Control-Expose-Headers "ETag, Location, DAV, Allow, X-Sync-Token" always;
64 add_header Access-Control-Max-Age 86400 always;
65 add_header Vary Origin always;
66 if ($radicale_cors_preflight) {
67 return 204;
68 }
69 '';
70in
71{
72 services.radicale = {
73 enable = true;
74 settings = {
75 server = {
76 hosts = [ "127.0.0.1:5232" ];
77 };
78 auth = {
79 type = "htpasswd";
80 htpasswd_filename = "/run/radicale/htpasswd";
81 htpasswd_encryption = "bcrypt";
82 };
83 storage = {
84 filesystem_folder = "/var/lib/radicale/collections";
85 };
86 web = {
87 type = "internal";
88 };
89 };
90 # Whitelist, first match wins (sections are emitted sorted by name, hence
91 # the numeric prefixes); anything unmatched is denied. Every user reads and
92 # writes the events of the one calendar (d/o: but may not delete or
93 # overwrite the collection itself) and may only read the root and lass's
94 # home, which is enough for CalDAV discovery. No W/w anywhere else, so no
95 # MKCALENDAR and no auto-created principals.
96 rights =
97 let
98 anyUser = lib.concatStringsSep "|" users;
99 in
100 {
101 "10-calendar" = {
102 user = anyUser;
103 collection = calendar;
104 permissions = "rwdo";
105 };
106 "20-root" = {
107 user = anyUser;
108 collection = "";
109 permissions = "R";
110 };
111 "21-home" = {
112 user = anyUser;
113 collection = lib.head (lib.splitString "/" calendar);
114 permissions = "R";
115 };
116 };
117 };
118
119 systemd.services.radicale.serviceConfig = {
120 RuntimeDirectory = "radicale";
121 LoadCredential = loadCredentials;
122 };
123 systemd.services.radicale.preStart = lib.mkBefore ''
124 ${assembleHtpasswd} > /run/radicale/htpasswd
125 '';
126
127 clan.core.vars.generators = userGenerators;
128
129 # Let the web client's origin talk CalDAV to radicale. Preflights carry no
130 # credentials and are answered here; a plain DAV OPTIONS (no
131 # Access-Control-Request-Method) still reaches radicale.
132 services.nginx.appendHttpConfig = ''
133 map $http_origin $radicale_cors_origin {
134 default "";
135 "https://${webClient}" $http_origin;
136 }
137 map "$request_method:$http_access_control_request_method" $radicale_cors_preflight {
138 default 0;
139 "~^OPTIONS:." 1;
140 }
141 # Top-level navigations send no Origin; the web client's GET / probes
142 # (CalDAV discovery via /.well-known/caldav) do and must reach radicale.
143 map "$request_method:$http_origin" $radicale_root_redirect {
144 default 0;
145 "GET:" 1;
146 }
147 '';
148
149 services.nginx.virtualHosts."cal.lassul.us" = {
150 forceSSL = true;
151 enableACME = true;
152 # Browsers opening the bare domain get the web client; CalDAV discovery
153 # (PROPFIND /, or the web client's cross-origin GET /) still reaches
154 # radicale.
155 locations."= /" = {
156 proxyPass = "http://127.0.0.1:5232";
157 extraConfig =
158 cors
159 + ''
160 if ($radicale_root_redirect) {
161 return 302 https://${webClient}/;
162 }
163 ''
164 + proxyHeaders;
165 };
166 locations."/" = {
167 proxyPass = "http://127.0.0.1:5232";
168 extraConfig = cors + proxyHeaders;
169 };
170 };
171
172 services.nginx.virtualHosts.${webClient} = {
173 forceSSL = true;
174 enableACME = true;
175 root = calino;
176 locations."/".extraConfig = ''
177 try_files $uri /index.html;
178 add_header Cache-Control no-cache;
179 '';
180 # Vite puts content-hashed bundles here.
181 locations."/assets/".extraConfig = ''
182 add_header Cache-Control "public, max-age=31536000, immutable";
183 '';
184 };
185}