1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
{ depot, pkgs, lib, config, ... }:

let
  cfg = config.profpatsch.webchat;
  webchat = depot.users.Profpatsch.webchat;
in
{
  options.profpatsch.webchat = {
    enable = lib.mkEnableOption "webchat minimal E2EE Matrix chat webapp";

    # Note there is no `room` option: webchat serves every room the account has
    # joined (hicli syncs them all regardless), listing them at `/`.

    address = lib.mkOption {
      type = lib.types.str;
      example = "100.89.52.54";
      description = ''
        Address to bind to. webchat has NO authentication of its own — anyone
        who can reach this address can read and send messages as you, in every
        room the account has joined. Bind it to a tailscale address only, and
        do not open the port in the public firewall (see the
        interfaces.tailscale0 stanza in legosi.nix).
      '';
    };

    port = lib.mkOption {
      type = lib.types.port;
      default = 8775;
      description = "Port for the webchat service.";
    };

    user = lib.mkOption {
      type = lib.types.str;
      default = "profpatsch";
      description = ''
        User to run as. Note this is NOT a DynamicUser service: the Matrix
        session (access token, olm identity, cross-signing keys) is bootstrapped
        interactively with `webchat login` and must persist across restarts and
        redeploys, so it needs a stable uid owning the state directory.
      '';
    };

    stateDir = lib.mkOption {
      type = lib.types.str;
      default = "/var/lib/webchat";
      description = ''
        Directory holding webchat.db and the crypto pickle key. Losing the
        pickle key invalidates the olm account and requires a fresh login.
      '';
    };
  };

  config = lib.mkIf cfg.enable {
    systemd.services.webchat = {
      description = "webchat — minimal E2EE Matrix chat webapp";
      wantedBy = [ "multi-user.target" ];
      after = [ "network-online.target" "tailscaled.service" ];
      wants = [ "network-online.target" ];

      serviceConfig = {
        ExecStart = lib.escapeShellArgs [
          "${webchat}/bin/webchat"
          "serve"
          "--addr"
          "${cfg.address}:${toString cfg.port}"
          "--db"
          "${cfg.stateDir}/webchat.db"
        ];
        User = cfg.user;
        Restart = "on-failure";
        RestartSec = 5;

        # The state dir holds long-lived secrets (access token, olm keys).
        StateDirectory = builtins.baseNameOf cfg.stateDir;
        StateDirectoryMode = "0700";

        # Hardening. Not DynamicUser (see the `user` option), so lock it down
        # by hand instead.
        NoNewPrivileges = true;
        PrivateTmp = true;
        PrivateDevices = true;
        ProtectSystem = "strict";
        ProtectHome = true;
        ProtectKernelTunables = true;
        ProtectKernelModules = true;
        ProtectControlGroups = true;
        RestrictNamespaces = true;
        RestrictRealtime = true;
        RestrictSUIDSGID = true;
        LockPersonality = true;
        MemoryDenyWriteExecute = true;
        SystemCallArchitectures = "native";
        SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
        # Only needs to talk to the homeserver over TCP and listen locally.
        RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
      };
    };
  };
}