nix-bitcoin/modules/clightning-replication.nix
2024-07-18 20:57:00 +02:00

231 lines
7.7 KiB
Nix

{ config, lib, pkgs, ... }:
with lib;
let
options.services.clightning.replication = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Enable live replication of the clightning database.
This prevents losing off-chain funds when the primary wallet file becomes
inaccessible.
For setting the destination, you can either define option `sshfs.destination`
or `local.directory`.
When `encrypt` is `false`, file `lightningd.sqlite3` is written to the destination.
When `encrypt` is `true`, directory `lightningd-db` is written to the destination.
It includes the encrypted database and gocryptfs metadata.
See also: https://github.com/ElementsProject/lightning/blob/master/doc/BACKUP.md
'';
};
sshfs = {
destination = mkOption {
type = types.nullOr types.str;
default = null;
example = "user@10.0.0.1:directory";
description = ''
The SSH destination for which a SSHFS will be mounted.
`directory` is relative to the home of `user`.
A SSH key is automatically generated and stored in file
`$secretsDir/clightning-replication-ssh`.
The SSH server must allow logins via this key.
I.e., the `authorized_keys` file of `user` must contain
`$secretsDir/clightning-replication-ssh.pub`.
'';
};
port = mkOption {
type = types.port;
default = 22;
description = "SSH port of the remote server.";
};
sshOptions = mkOption {
type = with types; listOf str;
default = [ "reconnect" "ServerAliveInterval=50" ];
description = "SSH options used for mounting the SSHFS.";
};
};
local = {
directory = mkOption {
type = types.nullOr types.path;
default = null;
example = "/var/backup/clightning";
description = ''
This option can be specified instead of `sshfs.destination` to enable
replication to a local directory.
If `local.setupDirectory` is disabled, the directory
- must already exist when `clightning.service` (or `clightning-replication-mounts.service`
if `encrypt` is `true`) starts.
- must have write permissions for the `clightning` user.
This option is also useful if you want to use a custom remote destination,
like a NFS or SMB share.
'';
};
setupDirectory = mkOption {
type = types.bool;
default = true;
description = ''
Create `local.directory` if it doesn't exist and set write permissions
for the `clightning` user.
'';
};
};
encrypt = mkOption {
type = types.bool;
default = false;
description = ''
Whether to encrypt the replicated database with gocryptfs.
The encryption password is automatically generated and stored
in file {file}`$secretsDir/clightning-replication-password`.
'';
};
};
cfg = config.services.clightning.replication;
inherit (config.services) clightning;
secretsDir = config.nix-bitcoin.secretsDir;
network = config.services.bitcoind.makeNetworkName "bitcoin" "regtest";
user = clightning.user;
group = clightning.group;
useSshfs = cfg.sshfs.destination != null;
useMounts = useSshfs || cfg.encrypt;
localDir = cfg.local.directory;
mountsDir = "/var/cache/clightning-replication";
sshfsDir = "${mountsDir}/sshfs";
plaintextDir = "${mountsDir}/plaintext";
destDir =
if cfg.encrypt then
plaintextDir
else if useSshfs then
sshfsDir
else
localDir;
in {
inherit options;
config = mkIf cfg.enable {
assertions = [
{ assertion = useSshfs || (localDir != null);
message = ''
services.clightning.replication: One of `sshfs.destination` or
`local.directory` must be set.
'';
}
{ assertion = !useSshfs || (localDir == null);
message = ''
services.clightning.replication: Only one of `sshfs.destination` and
`local.directory` must be set.
'';
}
];
environment.systemPackages = optionals cfg.encrypt [ pkgs.gocryptfs ];
systemd.tmpfiles.rules = optional (localDir != null && cfg.local.setupDirectory)
"d '${localDir}' 0770 ${user} ${group} - -";
services.clightning.wallet = let
mainDB = "${clightning.dataDir}/${network}/lightningd.sqlite3";
replicaDB = "${destDir}/lightningd.sqlite3";
in "sqlite3://${mainDB}:${replicaDB}";
systemd.services.clightning = {
bindsTo = mkIf useMounts [ "clightning-replication-mounts.service" ];
serviceConfig.ReadWritePaths = [
# We can't simply set `destDir` here because it might point to
# a FUSE mount.
# FUSE mounts can only be set up as `ReadWritePaths` by systemd when they
# are accessible by root.
# But FUSE mounts are only accessible by the mounting user and
# not by root.
# (This could be circumvented by FUSE-mounting `destDir` with option `allow_other`,
# but this would grant access to all users.)
(if useMounts then mountsDir else localDir)
];
};
systemd.services.clightning-replication-mounts = mkIf useMounts {
requiredBy = [ "clightning.service" ];
before = [ "clightning.service" ];
wants = [ "nix-bitcoin-secrets.target" ];
after = [ "nix-bitcoin-secrets.target" ];
path = [
# Includes
# - The SUID-wrapped `fusermount` binary which enables FUSE
# for non-root users
# - The SUID-wrapped `mount` binary, used for unmounting
"/run/wrappers"
] ++ optionals cfg.encrypt [
# Includes `logger`, required by gocryptfs
pkgs.util-linux
];
script =
optionalString useSshfs ''
mkdir -p ${sshfsDir}
${pkgs.sshfs}/bin/sshfs ${cfg.sshfs.destination} -p ${toString cfg.sshfs.port} ${sshfsDir} \
-o ${builtins.concatStringsSep "," ([
"IdentityFile='${secretsDir}'/clightning-replication-ssh-key"
] ++ cfg.sshfs.sshOptions)}
'' +
optionalString cfg.encrypt ''
cipherDir="${if useSshfs then sshfsDir else localDir}/lightningd-db"
mkdir -p "$cipherDir" ${plaintextDir}
gocryptfs=(${pkgs.gocryptfs}/bin/gocryptfs -passfile '${secretsDir}/clightning-replication-password')
# 1. init
if [[ ! -e $cipherDir/gocryptfs.conf ]]; then
"''${gocryptfs[@]}" -init "$cipherDir"
fi
# 2. mount
"''${gocryptfs[@]}" "$cipherDir" ${plaintextDir}
'';
postStop =
optionalString cfg.encrypt ''
umount ${plaintextDir} || true
'' +
optionalString useSshfs ''
umount ${sshfsDir}
'';
serviceConfig = {
StopPropagatedFrom = [ "clightning.service" ];
CacheDirectory = "clightning-replication";
CacheDirectoryMode = "770";
User = user;
RemainAfterExit = "yes";
Type = "oneshot";
};
};
nix-bitcoin = mkMerge [
(mkIf useSshfs {
secrets.clightning-replication-ssh-key = {
user = user;
permissions = "400";
};
generateSecretsCmds.clightning-replication-ssh-key = ''
if [[ ! -f clightning-replication-ssh-key ]]; then
${pkgs.openssh}/bin/ssh-keygen -t ed25519 -q -N "" -C "" -f clightning-replication-ssh-key
fi
'';
})
(mkIf cfg.encrypt {
secrets.clightning-replication-password.user = user;
generateSecretsCmds.clightning-replication-password = ''
makePasswordSecret clightning-replication-password
'';
})
];
};
}