This repository has no description
5.9 kB
187 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 lass.backup.state.radicale.folders = [ "/var/lib/radicale" ];
120
121 systemd.services.radicale.serviceConfig = {
122 RuntimeDirectory = "radicale";
123 LoadCredential = loadCredentials;
124 };
125 systemd.services.radicale.preStart = lib.mkBefore ''
126 ${assembleHtpasswd} > /run/radicale/htpasswd
127 '';
128
129 clan.core.vars.generators = userGenerators;
130
131 # Let the web client's origin talk CalDAV to radicale. Preflights carry no
132 # credentials and are answered here; a plain DAV OPTIONS (no
133 # Access-Control-Request-Method) still reaches radicale.
134 services.nginx.appendHttpConfig = ''
135 map $http_origin $radicale_cors_origin {
136 default "";
137 "https://${webClient}" $http_origin;
138 }
139 map "$request_method:$http_access_control_request_method" $radicale_cors_preflight {
140 default 0;
141 "~^OPTIONS:." 1;
142 }
143 # Top-level navigations send no Origin; the web client's GET / probes
144 # (CalDAV discovery via /.well-known/caldav) do and must reach radicale.
145 map "$request_method:$http_origin" $radicale_root_redirect {
146 default 0;
147 "GET:" 1;
148 }
149 '';
150
151 services.nginx.virtualHosts."cal.lassul.us" = {
152 forceSSL = true;
153 enableACME = true;
154 # Browsers opening the bare domain get the web client; CalDAV discovery
155 # (PROPFIND /, or the web client's cross-origin GET /) still reaches
156 # radicale.
157 locations."= /" = {
158 proxyPass = "http://127.0.0.1:5232";
159 extraConfig =
160 cors
161 + ''
162 if ($radicale_root_redirect) {
163 return 302 https://${webClient}/;
164 }
165 ''
166 + proxyHeaders;
167 };
168 locations."/" = {
169 proxyPass = "http://127.0.0.1:5232";
170 extraConfig = cors + proxyHeaders;
171 };
172 };
173
174 services.nginx.virtualHosts.${webClient} = {
175 forceSSL = true;
176 enableACME = true;
177 root = calino;
178 locations."/".extraConfig = ''
179 try_files $uri /index.html;
180 add_header Cache-Control no-cache;
181 '';
182 # Vite puts content-hashed bundles here.
183 locations."/assets/".extraConfig = ''
184 add_header Cache-Control "public, max-age=31536000, immutable";
185 '';
186 };
187}