Phase 3: Extract and sanitize Matrix platform modules from ops-base

Extracted modules:
- Matrix homeserver (matrix-continuwuity.nix)
- mautrix bridges (slack, whatsapp, gmessages)
- Security modules (fail2ban, ssh-hardening)
- Development services module
- Matrix secrets module

All modules sanitized to remove personal information:
- Domains: example.com, matrix.example.org
- IPs: 10.0.0.x, 203.0.113.10
- Paths: /home/user, /path/to/ops-base
- Emails: admin@example.com

Configuration:
- Updated flake.nix with sops-nix and nixpkgs-unstable
- Updated hosts/ops-jrz1.nix to import all extracted modules
- Added example files (secrets, minimal config)
- Generated flake.lock

Generated with Claude Code - https://claude.com/claude-code
This commit is contained in:
Dan 2025-10-13 14:51:14 -07:00
parent 6a26ca12ca
commit ab5aebb161
18 changed files with 2929 additions and 23 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ result-*
# Staging directories (temporary extraction workspace)
staging/
staging-sanitized/
# Secrets (never commit real secrets)
secrets/*.yaml

131
configurations/dev-vps.nix Normal file
View file

@ -0,0 +1,131 @@
# NixOS configuration for development VPS
# Simple setup for Matrix + Forgejo + Slack bridge testing
{ config, pkgs, lib, ... }:
{
imports = [
../modules/dev-services.nix
];
# Basic boot configuration for VPS
boot = {
loader = {
grub = {
enable = true;
device = "/dev/vda"; # Common for cloud VPS
useOSProber = false;
};
};
# Cloud VPS typically uses virtio
initrd.availableKernelModules = [
"virtio_pci"
"virtio_blk"
"virtio_net"
"virtio_scsi"
];
};
# Network configuration
networking = {
hostName = "dev-matrix-vps";
# Most VPS providers use DHCP
useDHCP = false;
interfaces.ens3 = { # Common interface name, adjust as needed
useDHCP = true;
};
enableIPv6 = true;
# Firewall - only expose what's needed
firewall = {
enable = true;
allowedTCPPorts = [
22 # SSH
80 # HTTP
443 # HTTPS
3000 # Forgejo (for testing, remove in production)
8008 # Matrix (for testing, remove in production)
];
allowPing = true;
};
};
# SSH configuration
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "prohibit-password";
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
};
};
# Admin user
users.users.admin = {
isNormalUser = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
# Add your SSH public key here
# "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI..."
];
};
# Enable sudo without password for admin (dev environment)
security.sudo.wheelNeedsPassword = false;
# Enable dev services stack
services.dev-platform = {
enable = true;
domain = "localhost"; # Change to your domain or IP
matrix = {
enable = true;
serverName = "dev.matrix";
};
forgejo = {
enable = true;
subdomain = "git";
};
slackBridge = {
enable = true;
workspace = ""; # Will be configured via secrets
};
};
# Basic monitoring
services.netdata = {
enable = true;
config = {
global = {
"bind to" = "127.0.0.1";
};
};
};
# Automatic garbage collection
nix.gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 7d";
};
# Enable flakes and optimize for deployment
nix.settings = {
experimental-features = [ "nix-command" "flakes" ];
# Optimize for builds and downloads
max-jobs = "auto";
cores = 0; # Use all cores
substituters = [
"https://cache.nixos.org"
];
trusted-public-keys = [
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
];
};
system.stateVersion = "24.11";
}

View file

@ -0,0 +1,184 @@
# NixOS configuration for Vultr development VPS
# Optimized for Matrix + Forgejo deployment without federation
{ config, pkgs, lib, ... }:
{
imports = [
../modules/dev-services.nix
./vultr-hardware.nix
];
# sops-nix secrets management
sops = {
defaultSopsFile = ../secrets/secrets.yaml;
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
# Secret declarations
secrets."matrix-registration-token" = {
mode = "0400";
};
secrets."acme-email" = {
mode = "0400";
# Using direct email value, no secret needed
};
};
# Boot configuration for Vultr VPS (Legacy BIOS mode)
boot = {
loader = {
grub = {
enable = true;
device = "/dev/vda"; # Legacy BIOS - install to MBR
efiSupport = false;
useOSProber = false;
};
};
# Vultr uses virtio drivers
initrd.availableKernelModules = [
"virtio_pci"
"virtio_blk"
"virtio_net"
"virtio_scsi"
];
};
# Filesystem configuration managed by vultr-hardware.nix
# Boot partition, root partition, and swap declared via generated hardware config
# Network configuration for Vultr
networking = {
hostName = "matrix";
# Vultr-specific network interface
useDHCP = false;
interfaces.ens3 = { # Vultr uses ens3
useDHCP = true;
};
enableIPv6 = true;
# Firewall - only expose reverse proxy ports
firewall = {
enable = true;
allowedTCPPorts = [
22 # SSH
80 # HTTP (ACME challenges, redirects)
443 # HTTPS
];
allowPing = true;
logRefusedConnections = false; # Reduce log noise on public VPS
};
};
# SSH configuration - secure but accessible for development
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "prohibit-password"; # More secure than "yes"
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
X11Forwarding = false;
};
# Basic security settings
extraConfig = ''
MaxAuthTries 3
MaxSessions 10
ClientAliveInterval 300
ClientAliveCountMax 2
'';
};
# SSH key for root and admin users
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOqHsgAuD/8LL6HN3fo7X1ywryQG393pyQ19a154bO+h delpad-2025"
];
users.users.admin = {
isNormalUser = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOqHsgAuD/8LL6HN3fo7X1ywryQG393pyQ19a154bO+h delpad-2025"
];
};
# Enable sudo without password for development convenience
security.sudo.wheelNeedsPassword = false;
# Enable fail2ban for brute force protection
security.fail2ban-enhanced = {
enable = true;
bantime = "1h";
maxretry = 3;
};
# Enable ACME for TLS certificates
security.acme = {
acceptTerms = true;
defaults.email = "admin@example.com"; # Using direct email as ACME doesn't support emailFile
};
# Dev services stack - simplified without federation
services.dev-platform = {
enable = true;
domain = "example.com";
matrix = {
enable = true;
port = 8008;
};
forgejo = {
enable = true;
subdomain = "git";
port = 3000;
};
slackBridge = {
enable = true;
};
};
# Basic monitoring for development
services.netdata = {
enable = true;
config = {
global = {
"bind to" = "127.0.0.1"; # Localhost only for security
};
};
};
# Automatic garbage collection to manage disk space
nix.gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 7d";
};
# Allow insecure packages needed for Matrix bridges
nixpkgs.config.permittedInsecurePackages = [
"olm-3.2.16"
];
# NixOS configuration optimized for VPS
nix.settings = {
experimental-features = [ "nix-command" "flakes" ];
# Optimize for VPS builds and downloads
max-jobs = "auto";
cores = 0; # Use all available cores
substituters = [
"https://cache.nixos.org"
];
trusted-public-keys = [
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
];
};
# Timezone and locale for VPS
time.timeZone = "UTC";
i18n.defaultLocale = "en_US.UTF-8";
system.stateVersion = "24.11";
}

View file

@ -0,0 +1,79 @@
# Minimal ops-jrz1 configuration example
# Demonstrates Matrix homeserver + single bridge deployment
{ config, pkgs, ... }:
{
imports = [
../../modules/matrix-continuwuity.nix
../../modules/mautrix-slack.nix
../../modules/security/ssh-hardening.nix
../../modules/security/fail2ban.nix
];
# Basic networking
networking = {
hostName = "matrix";
firewall = {
enable = true;
allowedTCPPorts = [ 22 80 443 8008 ];
};
};
# Matrix homeserver configuration
services.matrix-homeserver = {
enable = true;
domain = "matrix.example.org";
port = 8008;
enableRegistration = true;
enableFederation = false;
};
# Slack bridge configuration
services.mautrix-slack = {
enable = true;
matrix = {
homeserverUrl = "http://127.0.0.1:8008";
serverName = "matrix.example.org";
};
bridge = {
permissions = {
"matrix.example.org" = "user";
"@admin:matrix.example.org" = "admin";
};
};
};
# Security hardening
security = {
fail2ban-enhanced = {
enable = true;
bantime = "1h";
maxretry = 3;
};
acme = {
acceptTerms = true;
defaults.email = "admin@example.com";
};
};
# SSH hardening
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "prohibit-password";
PasswordAuthentication = false;
};
};
# PostgreSQL for bridge database
services.postgresql = {
enable = true;
ensureDatabases = [ "mautrix_slack" ];
ensureUsers = [{
name = "mautrix_slack";
ensureDBOwnership = true;
}];
};
system.stateVersion = "24.05";
}

65
flake.lock Normal file
View file

@ -0,0 +1,65 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1735563628,
"narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1760284886,
"narHash": "sha256-TK9Kr0BYBQ/1P5kAsnNQhmWWKgmZXwUQr4ZMjCzWf2c=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "cf3f5c4def3c7b5f1fc012b3d839575dbe552d43",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"nixpkgs-unstable": "nixpkgs-unstable",
"sops-nix": "sops-nix"
}
},
"sops-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1760240450,
"narHash": "sha256-sa9bS9jSyc4vH0jSWrUsPGdqtMvDwmkLg971ntWOo2U=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "41fd1f7570c89f645ee0ada0be4e2d3c4b169549",
"type": "github"
},
"original": {
"owner": "Mic92",
"repo": "sops-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -3,24 +3,28 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
# sops-nix for secrets management (will be configured after extraction)
# sops-nix = {
# url = "github:Mic92/sops-nix";
# inputs.nixpkgs.follows = "nixpkgs";
# };
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, ... }@inputs: {
outputs = { self, nixpkgs, nixpkgs-unstable, sops-nix, ... }@inputs: {
nixosConfigurations = {
ops-jrz1 = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
pkgs-unstable = import nixpkgs-unstable {
system = "x86_64-linux";
config.allowUnfree = true;
};
};
modules = [
./configuration.nix
./hosts/ops-jrz1.nix
# sops-nix will be added after extraction
# inputs.sops-nix.nixosModules.sops
sops-nix.nixosModules.sops
];
};
};

View file

@ -1,34 +1,51 @@
{ config, pkgs, ... }:
{ config, pkgs, pkgs-unstable, ... }:
{
# ops-jrz1 dev/test server configuration
# This file will be populated with Matrix platform modules and server-specific settings
# Imports extracted Matrix modules from ops-base
imports = [
# Hardware configuration will be added based on server specs
# ./hardware-configuration.nix
# Matrix platform modules (to be imported after extraction)
# ../modules/matrix-continuwuity.nix
# ../modules/mautrix-slack.nix
# ../modules/mautrix-whatsapp.nix
# ../modules/mautrix-gmessages.nix
# ../modules/security/fail2ban.nix
# ../modules/security/ssh-hardening.nix
# Matrix platform modules
../modules/matrix-continuwuity.nix
../modules/mautrix-slack.nix
../modules/mautrix-whatsapp.nix
../modules/mautrix-gmessages.nix
../modules/dev-services.nix
../modules/security/fail2ban.nix
../modules/security/ssh-hardening.nix
../modules/matrix-secrets
];
# System configuration
networking.hostName = "ops-jrz1";
# Placeholder for Matrix homeserver configuration
# services.matrix-continuwuity = {
# Example Matrix homeserver configuration (disabled by default)
# Uncomment and configure for actual deployment:
# services.matrix-homeserver = {
# enable = true;
# domain = "REPLACE_ME"; # ops-jrz1 domain
# domain = "matrix.example.org";
# port = 8008;
# enableRegistration = true;
# enableFederation = false;
# };
# Placeholder for bridge configurations
# Bridges will be configured after modules are extracted
# Example mautrix-slack bridge configuration (disabled by default)
# services.mautrix-slack = {
# enable = true;
# matrix = {
# homeserverUrl = "http://127.0.0.1:8008";
# serverName = "matrix.example.org";
# };
# bridge = {
# permissions = {
# "matrix.example.org" = "user";
# "@admin:matrix.example.org" = "admin";
# };
# };
# };
system.stateVersion = "24.05";
}

308
modules/dev-services.nix Normal file
View file

@ -0,0 +1,308 @@
# Development services module - Matrix, Forgejo, and Slack bridge
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.services.dev-platform;
in
{
options.services.dev-platform = {
enable = mkEnableOption "development platform with Matrix and Forgejo";
domain = mkOption {
type = types.str;
default = "localhost";
description = "Base domain for services";
};
matrix = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable Matrix server";
};
serverName = mkOption {
type = types.str;
default = cfg.domain;
description = "Matrix server name";
};
port = mkOption {
type = types.port;
default = 8008;
description = "Matrix server port";
};
};
forgejo = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable Forgejo git service";
};
subdomain = mkOption {
type = types.str;
default = "git";
description = "Subdomain for Forgejo";
};
port = mkOption {
type = types.port;
default = 3000;
description = "Forgejo port";
};
};
slackBridge = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable Slack bridge";
};
workspace = mkOption {
type = types.str;
default = "";
description = "Slack workspace name";
};
port = mkOption {
type = types.port;
default = 29319;
description = "Slack bridge port";
};
};
};
config = mkIf cfg.enable {
# PostgreSQL for Forgejo and bridge services (Matrix uses RocksDB)
services.postgresql = {
enable = true;
ensureDatabases = [
"forgejo"
] ++ optional cfg.slackBridge.enable "mautrix_slack";
ensureUsers = [
{
name = "forgejo";
ensureDBOwnership = true;
}
] ++ optional cfg.slackBridge.enable {
name = "mautrix_slack";
ensureDBOwnership = true;
};
};
# Matrix Continuwuity server
systemd.services.matrix-continuwuity = mkIf cfg.matrix.enable {
description = "Continuwuity Matrix homeserver";
after = [ "network.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
preStart = ''
# Generate config file at runtime with secret injection from systemd credentials
cat > /var/lib/matrix-continuwuity/continuwuity.toml <<EOF
[global]
server_name = "${cfg.matrix.serverName}"
address = "127.0.0.1"
port = ${toString cfg.matrix.port}
allow_registration = true
registration_token = "$(cat $CREDENTIALS_DIRECTORY/matrix-registration-token)"
allow_encryption = true
allow_federation = false
database_backend = "rocksdb"
database_path = "/var/lib/matrix-continuwuity/db/"
log = "info"
admin_room_tag = "m.server_notice"
EOF
'';
serviceConfig = {
Type = "simple";
DynamicUser = true;
StateDirectory = "matrix-continuwuity";
WorkingDirectory = "/var/lib/matrix-continuwuity";
# Load secrets via systemd credentials (proper way for DynamicUser)
LoadCredential = "matrix-registration-token:/run/secrets/matrix-registration-token";
ExecStart = "${pkgs.matrix-continuwuity}/bin/conduwuit -c /var/lib/matrix-continuwuity/continuwuity.toml";
Restart = "always";
RestartSec = "10s";
# Security hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
};
};
# Forgejo git service
services.forgejo = mkIf cfg.forgejo.enable {
enable = true;
stateDir = "/var/lib/forgejo";
database = {
type = "postgres";
host = "/run/postgresql";
name = "forgejo";
user = "forgejo";
createDatabase = false; # We handle this with ensureDatabases
};
settings = {
server = {
DOMAIN = "${cfg.forgejo.subdomain}.${cfg.domain}";
ROOT_URL = "https://${cfg.forgejo.subdomain}.${cfg.domain}";
PROTOCOL = "http";
HTTP_ADDR = "127.0.0.1";
HTTP_PORT = cfg.forgejo.port;
};
service = {
DISABLE_REGISTRATION = true; # Disable public registration
DEFAULT_KEEP_EMAIL_PRIVATE = true;
DEFAULT_ALLOW_CREATE_ORGANIZATION = true;
};
log = {
LEVEL = "Info";
MODE = "console";
};
repository = {
DEFAULT_BRANCH = "main";
};
ui = {
DEFAULT_THEME = "arc-green";
};
# Enable Actions for CI/CD
actions = {
ENABLED = true;
};
};
lfs.enable = true;
};
# mautrix-slack bridge
services.mautrix-slack = mkIf cfg.slackBridge.enable {
enable = true;
package = pkgs.mautrix-slack or (pkgs.callPackage ../pkgs/mautrix-slack {});
matrix = {
homeserverUrl = "http://localhost:${toString cfg.matrix.port}";
serverName = cfg.matrix.serverName;
};
appservice = {
port = cfg.slackBridge.port;
hostname = "127.0.0.1";
};
database = {
type = "postgres";
uri = "postgresql:///mautrix_slack?host=/run/postgresql";
};
};
# Basic Nginx reverse proxy
services.nginx = {
enable = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
# Rate limiting for login endpoints
appendHttpConfig = ''
limit_req_zone $binary_remote_addr zone=logins:10m rate=5r/m;
'';
virtualHosts = {
# Matrix endpoints
"${cfg.domain}" = mkIf cfg.matrix.enable {
enableACME = true;
forceSSL = true;
extraConfig = ''
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
'';
locations = {
"~ ^/_matrix/client/(r0|v3)/login" = {
proxyPass = "http://localhost:${toString cfg.matrix.port}";
proxyWebsockets = true;
extraConfig = ''
limit_req zone=logins burst=10 nodelay;
'';
};
"/_matrix" = {
proxyPass = "http://localhost:${toString cfg.matrix.port}";
proxyWebsockets = true;
};
"/.well-known/matrix/server" = {
extraConfig = ''
add_header Content-Type application/json;
return 200 '{"m.server": "${cfg.matrix.serverName}:443"}';
'';
};
"/.well-known/matrix/client" = {
extraConfig = ''
add_header Content-Type application/json;
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver": {"base_url": "https://${cfg.domain}"}}';
'';
};
};
};
# Forgejo git service (subdomain-based, requires DNS)
"${cfg.forgejo.subdomain}.${cfg.domain}" = mkIf cfg.forgejo.enable {
enableACME = true;
forceSSL = true;
extraConfig = ''
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
'';
locations."/user/login" = {
proxyPass = "http://localhost:${toString cfg.forgejo.port}";
proxyWebsockets = true;
extraConfig = ''
limit_req zone=logins burst=10 nodelay;
'';
};
locations."/user/sign_up" = {
proxyPass = "http://localhost:${toString cfg.forgejo.port}";
proxyWebsockets = true;
extraConfig = ''
limit_req zone=logins burst=10 nodelay;
'';
};
locations."/" = {
proxyPass = "http://localhost:${toString cfg.forgejo.port}";
proxyWebsockets = true;
extraConfig = ''
client_max_body_size 512M;
'';
};
};
};
};
# Systemd tmpfiles for data directories
# Note: matrix-continuwuity directory managed by StateDirectory with DynamicUser
systemd.tmpfiles.rules = [
"d /var/lib/forgejo 0750 forgejo forgejo -"
] ++ optional cfg.slackBridge.enable
"d /var/lib/mautrix-slack 0750 mautrix_slack mautrix_slack -";
};
}

View file

@ -0,0 +1,119 @@
# NixOS module for Continuwuity Matrix homeserver
# Portable, modular configuration with clean enable/disable
# Creates systemd service manually since services.matrix-continuwuity not in stable
{ config, pkgs, lib, pkgs-unstable, ... }:
with lib;
let
cfg = config.services.matrix-homeserver;
continuwuityPkg = pkgs-unstable.matrix-continuwuity;
# Generate TOML configuration
configFile = pkgs.writeText "continuwuity.toml" ''
[global]
server_name = "${cfg.domain}"
address = "0.0.0.0"
port = ${toString cfg.port}
allow_registration = ${boolToString cfg.enableRegistration}
allow_encryption = true
allow_federation = ${boolToString cfg.enableFederation}
database_backend = "rocksdb"
database_path = "${cfg.dataDir}/db/"
log = "info,continuwuity=debug"
${optionalString cfg.enableFederation ''
trusted_servers = ["matrix.org"]
''}
'';
in
{
options.services.matrix-homeserver = {
enable = mkEnableOption "Continuwuity Matrix homeserver";
domain = mkOption {
type = types.str;
default = "10.0.0.40";
description = "Domain or IP for Matrix server";
};
port = mkOption {
type = types.port;
default = 8008;
description = "Port for Matrix server";
};
enableRegistration = mkOption {
type = types.bool;
default = true;
description = "Allow new user registration";
};
enableFederation = mkOption {
type = types.bool;
default = false;
description = "Enable federation with other Matrix servers";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/continuwuity";
description = "Data directory for Matrix server";
};
};
config = mkIf cfg.enable {
# Create continuwuity user and group
users.users.continuwuity = {
description = "Continuwuity Matrix server user";
group = "continuwuity";
home = cfg.dataDir;
createHome = true;
isSystemUser = true;
};
users.groups.continuwuity = {};
# Systemd service for Continuwuity
systemd.services.continuwuity = {
description = "Continuwuity Matrix homeserver";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = "continuwuity";
Group = "continuwuity";
WorkingDirectory = cfg.dataDir;
ExecStart = "${continuwuityPkg}/bin/conduwuit -c ${configFile}";
Restart = "always";
RestartSec = "10s";
# Security hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
};
preStart = ''
# Ensure data directory exists with proper permissions
mkdir -p ${cfg.dataDir}/db
chown -R continuwuity:continuwuity ${cfg.dataDir}
chmod -R 750 ${cfg.dataDir}
'';
};
# Open firewall port only when service is enabled
networking.firewall.allowedTCPPorts = [ cfg.port ];
# Ensure data directories exist with proper permissions
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 continuwuity continuwuity -"
"d ${cfg.dataDir}/db 0750 continuwuity continuwuity -"
];
};
}

View file

@ -0,0 +1,23 @@
# Matrix secret mapping module
Provides a declarative helper for mapping entries in `secrets/secrets.yaml`
to the file paths expected by Matrix services. Each secret is defined once,
then delivered via sops-nix to `/run/secrets/...` with the correct owner and
permissions.
Example:
```
{
matrix.secrets = {
"matrix-services.mautrix_whatsapp.as_token" = {
key = "matrix-services.mautrix_whatsapp.as_token";
path = "/run/secrets/mautrix-whatsapp-as-token";
user = "mautrix-whatsapp";
group = "mautrix-whatsapp";
mode = "0400";
};
# ...
};
}
```

View file

@ -0,0 +1,62 @@
{ lib, config, ... }:
{
options.matrix.secrets = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule (
{ name, ... }:
{ options = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable this Matrix secret.";
};
key = lib.mkOption {
type = lib.types.str;
description = "Key inside secrets/secrets.yaml.";
};
path = lib.mkOption {
type = lib.types.str;
description = "Path where decrypted secret should be written.";
};
user = lib.mkOption {
type = lib.types.str;
default = name;
description = "Owner user for the secret file.";
};
group = lib.mkOption {
type = lib.types.str;
default = name;
description = "Owner group for the secret file.";
};
mode = lib.mkOption {
type = lib.types.str;
default = "0400";
description = "File permission mode.";
};
};
}
));
default = {};
description = "Declarative mapping of Matrix service secrets to sops entries.";
};
config = let
secrets = config.matrix.secrets;
in {
assertions = [
{
assertion = lib.all (secret: lib.hasAttr "key" secret && lib.hasAttr "path" secret) (lib.attrValues secrets);
message = "Each matrix secret must define both `key` and `path`.";
}
];
sops.secrets = lib.mapAttrs' (_: secret:
lib.nameValuePair secret.key {
inherit (secret) path;
owner = secret.user;
group = secret.group;
mode = secret.mode;
}
) secrets;
};
}

View file

@ -0,0 +1,712 @@
# mautrix-gmessages Matrix-Google Messages bridge
# Bridges Google Messages (RCS/SMS/MMS) to Matrix via web interface
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.services.matrix-vm.mautrix-gmessages;
yamlFormat = pkgs.formats.yaml { };
defaultPermissions = {
"*" = "relay";
"${cfg.matrix.serverName}" = "user";
};
effectivePermissions =
if cfg.bridge.permissions != {} then cfg.bridge.permissions else defaultPermissions;
exampleConfigPath = "${cfg.package}/share/mautrix-gmessages/example-config.yaml";
configOverrides = {
network = {
displayname_template = cfg.network.displaynameTemplate;
device_meta = {
os = cfg.network.deviceMeta.os;
browser = cfg.network.deviceMeta.browser;
type = cfg.network.deviceMeta.type;
};
aggressive_reconnect = cfg.network.aggressiveReconnect;
initial_chat_sync_count = cfg.network.initialChatSyncCount;
};
bridge = {
command_prefix = cfg.bridge.commandPrefix;
relay = {
enabled = cfg.bridge.relay.enabled;
admin_only = cfg.bridge.relay.adminOnly;
};
caption_in_message = cfg.bridge.captionInMessage;
portal_message_buffer = cfg.bridge.portalMessageBuffer;
permissions = effectivePermissions;
};
database = {
type = cfg.database.type;
uri = cfg.database.uri;
max_open_conns = cfg.database.maxOpenConnections;
max_idle_conns = cfg.database.maxIdleConnections;
};
homeserver = {
address = cfg.matrix.homeserverUrl;
domain = cfg.matrix.serverName;
};
appservice = {
address = "http://${cfg.appservice.hostname}:${toString cfg.appservice.port}";
hostname = cfg.appservice.hostname;
port = cfg.appservice.port;
id = cfg.appservice.id;
bot = {
username = cfg.appservice.senderLocalpart;
displayname = cfg.appservice.botDisplayName;
avatar = cfg.appservice.botAvatar;
};
username_template = "${cfg.appservice.userPrefix}{{.}}";
public_address = cfg.appservice.publicAddress;
};
double_puppet = {
servers = cfg.bridge.additionalDoublePuppetServers;
allow_discovery = cfg.bridge.allowDiscovery;
secrets = {};
};
encryption = {
allow = cfg.encryption.enable;
default = cfg.encryption.default;
require = cfg.encryption.require;
require_verification = cfg.encryption.requireVerification;
allow_key_sharing = cfg.encryption.allowKeySharing;
};
};
registrationBase = {
id = cfg.appservice.id;
url = "http://${cfg.appservice.hostname}:${toString cfg.appservice.port}";
as_token = "__AS_TOKEN__";
hs_token = "__HS_TOKEN__";
sender_localpart = cfg.appservice.senderLocalpart;
rate_limited = false;
namespaces = {
users = [
{
regex = "^@${cfg.appservice.senderLocalpart}:${cfg.matrix.serverName}$";
exclusive = true;
}
{
regex = "^@${cfg.appservice.userPrefix}.*:${cfg.matrix.serverName}$";
exclusive = true;
}
];
};
"de.sorunome.msc2409.push_ephemeral" = cfg.appservice.pushEphemeral;
receive_ephemeral = true;
};
registrationFile = yamlFormat.generate "mautrix-gmessages-registration.yaml" registrationBase;
# LoadCredential directives for secure secret injection
loadCredentials =
[
"as_token:${cfg.appservice.asTokenFile}"
"hs_token:${cfg.appservice.hsTokenFile}"
]
++ optional (cfg.appservice.provisioningSharedSecretFile != null)
"provisioning_shared_secret:${cfg.appservice.provisioningSharedSecretFile}"
++ optional (cfg.matrix.loginSharedSecretFile != null)
"login_shared_secret:${cfg.matrix.loginSharedSecretFile}";
in
{
options.services.matrix-vm.mautrix-gmessages = {
enable = mkEnableOption "mautrix-gmessages Matrix-Google Messages bridge";
package = mkOption {
type = types.package;
default = pkgs.mautrix-gmessages;
description = "Package providing the bridge executable.";
};
user = mkOption {
type = types.str;
default = "mautrix_gmessages";
description = "System user for the bridge.";
};
group = mkOption {
type = types.str;
default = "mautrix_gmessages";
description = "Primary group for the bridge.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/mautrix_gmessages";
description = "State directory.";
};
runtimeDirectory = mkOption {
type = types.str;
default = "mautrix_gmessages";
description = "Runtime directory name under /run.";
};
extraPackages = mkOption {
type = types.listOf types.package;
default = [];
description = "Additional packages exposed to the service PATH.";
};
extraConfig = mkOption {
type = yamlFormat.type;
default = {};
description = "Optional YAML fragment merged into the config.";
};
# Matrix homeserver configuration
matrix = {
homeserverUrl = mkOption {
type = types.str;
default = "http://127.0.0.1:6167";
description = "Matrix homeserver URL for bridge connections";
};
serverName = mkOption {
type = types.str;
description = "Matrix server name for bridge users";
};
verifySSL = mkOption {
type = types.bool;
default = true;
description = "Verify SSL certificates when connecting to homeserver";
};
asmux = mkOption {
type = types.bool;
default = false;
description = "Enable asmux protocol";
};
loginSharedSecretFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Shared secret file for automatic double puppeting login";
};
};
# Database configuration
database = {
type = mkOption {
type = types.str;
default = "postgres";
description = "Database type (sqlite3 or postgres)";
};
uri = mkOption {
type = types.str;
default = "sqlite:///var/lib/mautrix_gmessages/gmessages.db?_txlock=immediate";
description = "Database connection string";
};
maxOpenConnections = mkOption {
type = types.int;
default = 32;
description = "Maximum number of open database connections";
};
maxIdleConnections = mkOption {
type = types.int;
default = 4;
description = "Maximum number of idle database connections";
};
};
# Network configuration (formerly google_messages)
network = {
displaynameTemplate = mkOption {
type = types.str;
default = "{{or .FullName .PhoneNumber}}";
description = "Display name template for SMS users";
};
deviceMeta = {
os = mkOption {
type = types.str;
default = "mautrix-gmessages";
description = "OS name shown in paired devices list";
};
browser = mkOption {
type = types.enum ["OTHER" "CHROME" "FIREFOX" "SAFARI" "OPERA" "IE" "EDGE"];
default = "OTHER";
description = "Browser type shown to phone";
};
type = mkOption {
type = types.enum ["WEB" "TABLET" "PWA"];
default = "TABLET";
description = "Device type - affects icon and session limits";
};
};
aggressiveReconnect = mkOption {
type = types.bool;
default = false;
description = "Aggressively set bridge as active device if browser opens";
};
initialChatSyncCount = mkOption {
type = types.int;
default = 25;
description = "Number of chats to sync when connecting";
};
};
# Logging configuration
logging = {
level = mkOption {
type = types.str;
default = "info";
description = "Log level (debug, info, warn, error)";
};
};
# Bridge behavior configuration
bridge = {
commandPrefix = mkOption {
type = types.str;
default = "!gm";
description = "Command prefix for bridge commands";
};
permissions = mkOption {
type = types.attrsOf types.str;
default = {};
description = "Bridge permissions mapping";
};
relay = {
enabled = mkOption {
type = types.bool;
default = false;
description = "Enable relay mode";
};
adminOnly = mkOption {
type = types.bool;
default = true;
description = "Restrict relay to admins only";
};
};
captionInMessage = mkOption {
type = types.bool;
default = false;
description = "Include captions in message body";
};
portalMessageBuffer = mkOption {
type = types.int;
default = 128;
description = "Portal message buffer size";
};
additionalDoublePuppetServers = mkOption {
type = types.attrsOf types.str;
default = {};
description = "Additional servers for double puppeting";
};
allowDiscovery = mkOption {
type = types.bool;
default = true;
description = "Allow discovery of double puppet servers";
};
};
# Appservice configuration
appservice = {
id = mkOption {
type = types.str;
default = "gmessages";
description = "Appservice ID";
};
hostname = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Hostname for the appservice";
};
port = mkOption {
type = types.port;
default = 9556;
description = "Port for the appservice API";
};
senderLocalpart = mkOption {
type = types.str;
default = "gmessagesbot";
description = "Localpart of the bridge bot user";
};
botDisplayName = mkOption {
type = types.str;
default = "Google Messages Bridge Bot";
description = "Display name for the bridge bot";
};
botAvatar = mkOption {
type = types.str;
default = "mxc://maunium.net/ygtkteZsXnGJLJHRchUwYWak";
description = "Avatar MXC URI for the bridge bot";
};
userPrefix = mkOption {
type = types.str;
default = "_gmessages_";
description = "Prefix for Matrix users bridged from Google Messages";
};
aliasPrefix = mkOption {
type = types.str;
default = "gmessages_";
description = "Prefix for Matrix aliases/rooms";
};
publicAddress = mkOption {
type = types.nullOr types.str;
default = null;
description = "Public URL for the bridge (used for media endpoints).";
};
pushEphemeral = mkOption {
type = types.bool;
default = true;
description = "Enable push ephemeral events";
};
asTokenFile = mkOption {
type = types.path;
description = "Path to file containing appservice token";
};
hsTokenFile = mkOption {
type = types.path;
description = "Path to file containing homeserver token";
};
provisioningSharedSecretFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Path to file containing provisioning shared secret";
};
};
# Backfill configuration
backfill = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable message backfilling";
};
maxInitialMessages = mkOption {
type = types.int;
default = 50;
description = "Maximum messages to backfill in empty rooms";
};
maxCatchupMessages = mkOption {
type = types.int;
default = 500;
description = "Maximum missed messages to backfill after restart";
};
unreadHoursThreshold = mkOption {
type = types.int;
default = 720;
description = "Mark old backfilled chats as read after this many hours";
};
};
# Encryption configuration
encryption = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable Matrix end-to-end encryption";
};
default = mkOption {
type = types.bool;
default = false;
description = "Enable encryption by default in new portals";
};
require = mkOption {
type = types.bool;
default = false;
description = "Require encryption in all portals";
};
requireVerification = mkOption {
type = types.bool;
default = false;
description = "Require device verification for encryption";
};
allowKeySharing = mkOption {
type = types.bool;
default = false;
description = "Allow sharing encryption keys";
};
};
# Computed options (read-only)
registrationFile = mkOption {
type = types.str;
readOnly = true;
description = "Path to the generated registration file";
};
};
config = mkIf cfg.enable {
# User and group
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
extraGroups = [ "matrix-appservices" ];
home = cfg.dataDir;
createHome = true;
};
users.groups.${cfg.group} = {};
users.groups.matrix-appservices.members = lib.mkAfter [ cfg.user ];
# Systemd service with comprehensive hardening
systemd.services.mautrix-gmessages = {
description = "mautrix-gmessages Matrix-Google Messages bridge";
after = [ "network.target" "postgresql.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
SupplementaryGroups = [ "matrix-appservices" ];
PermissionsStartOnly = true;
WorkingDirectory = cfg.dataDir;
# Use StateDirectory for persistent config and data management
StateDirectory = cfg.user;
StateDirectoryMode = "0750";
LogsDirectory = cfg.user;
# LoadCredential directives for secure secret injection
LoadCredential = loadCredentials;
# Two-stage pre-start: directory setup as root, then config generation as service user
ExecStartPre = [
# Stage 1: Create config directory as root (+ prefix)
"+${pkgs.coreutils}/bin/mkdir -p /var/lib/${cfg.user}/config"
"+${pkgs.coreutils}/bin/chown -R ${cfg.user}:${cfg.group} /var/lib/${cfg.user}"
"+${pkgs.coreutils}/bin/chmod 750 /var/lib/${cfg.user}"
"+${pkgs.coreutils}/bin/chmod 750 /var/lib/${cfg.user}/config"
# Stage 2: Generate configuration using example template
"+${pkgs.writeShellScript "mautrix-gmessages-prepare-config" ''
set -euo pipefail
CONFIG_DIR=/var/lib/${cfg.user}/config
CONFIG_PATH="$CONFIG_DIR/config.yaml"
REG_PATH="$CONFIG_DIR/registration.yaml"
cp ${exampleConfigPath} "$CONFIG_PATH"
cp ${registrationFile} "$REG_PATH"
${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3 <<'PY'
import json
import os
import pathlib
import pwd
import grp
import shutil
import yaml
CONFIG_DIR = pathlib.Path('/var/lib/${cfg.user}/config')
CONFIG_PATH = CONFIG_DIR / 'config.yaml'
REG_PATH = CONFIG_DIR / 'registration.yaml'
SHARED_REG = pathlib.Path('/var/lib/matrix-appservices/mautrix_gmessages_registration.yaml')
with CONFIG_PATH.open('r', encoding='utf-8') as fh:
config = yaml.safe_load(fh)
with REG_PATH.open('r', encoding='utf-8') as fh:
registration = yaml.safe_load(fh)
overrides = json.loads(${lib.escapeShellArg (builtins.toJSON configOverrides)})
extra_cfg = json.loads(${lib.escapeShellArg (builtins.toJSON cfg.extraConfig)})
def deep_update(target, data):
for key, value in data.items():
if isinstance(value, dict) and value is not None:
target.setdefault(key, {})
deep_update(target[key], value)
else:
target[key] = value
deep_update(config, overrides)
if extra_cfg:
deep_update(config, extra_cfg)
perms = overrides.get('bridge', {}).get('permissions')
if perms is not None:
config.setdefault('bridge', {})['permissions'] = perms
dp_config = config.setdefault('double_puppet', {})
servers = overrides.get('double_puppet', {}).get('servers')
if servers is not None:
dp_config['servers'] = servers
dp_config['secrets'] = {}
creds_dir = os.environ.get('CREDENTIALS_DIRECTORY')
as_token = hs_token = provisioning_secret = login_shared_secret = None
if creds_dir:
creds_path = pathlib.Path(creds_dir)
def read_secret(name):
path = creds_path / name
return path.read_text(encoding='utf-8').strip() if path.exists() else None
as_token = read_secret('as_token')
hs_token = read_secret('hs_token')
provisioning_secret = read_secret('provisioning_shared_secret')
login_shared_secret = read_secret('login_shared_secret')
if as_token:
config.setdefault('appservice', {})['as_token'] = as_token
registration['as_token'] = as_token
if hs_token:
config.setdefault('appservice', {})['hs_token'] = hs_token
registration['hs_token'] = hs_token
if overrides.get('appservice', {}).get('public_address') is None:
config.setdefault('appservice', {})['public_address'] = None
if provisioning_secret:
config.setdefault('provisioning', {})['shared_secret'] = provisioning_secret
else:
config.setdefault('provisioning', {})['shared_secret'] = 'disable'
if login_shared_secret:
login_secret_value = login_shared_secret
dp_config.setdefault('secrets', {})['${cfg.matrix.serverName}'] = login_secret_value
with CONFIG_PATH.open('w', encoding='utf-8') as fh:
yaml.safe_dump(config, fh, default_flow_style=False, sort_keys=False)
with REG_PATH.open('w', encoding='utf-8') as fh:
yaml.safe_dump(registration, fh, default_flow_style=False, sort_keys=False)
uid = pwd.getpwnam('${cfg.user}').pw_uid
gid_service = grp.getgrnam('${cfg.group}').gr_gid
gid_shared = grp.getgrnam('matrix-appservices').gr_gid
os.chown(CONFIG_PATH, uid, gid_service)
os.chmod(CONFIG_PATH, 0o640)
os.chown(REG_PATH, uid, gid_service)
os.chmod(REG_PATH, 0o640)
SHARED_REG.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(REG_PATH, SHARED_REG)
os.chown(SHARED_REG, uid, gid_shared)
os.chmod(SHARED_REG, 0o640)
PY
''}"
];
# Start mautrix-gmessages with state directory config
ExecStart = "${cfg.package}/bin/mautrix-gmessages -c /var/lib/${cfg.user}/config/config.yaml";
# Restart policy
Restart = "always";
RestartSec = 10;
# Security hardening following NixOS best practices
# NoNewPrivileges = true; # Temporarily disabled to debug exit code 11
# ProtectSystem = "strict"; # Temporarily disabled to debug exit code 11
# ProtectHome = true; # Temporarily disabled to debug exit code 11
# PrivateTmp = true; # Temporarily disabled to debug exit code 11
# PrivateDevices = true; # Temporarily disabled to debug exit code 11
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
# Allow writing to data, log, and shared appservices directory
ReadWritePaths = [
cfg.dataDir
"/var/log/${cfg.user}"
"/var/lib/matrix-appservices"
];
# Network restrictions
# RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; # Temporarily disabled to debug exit code 11
# System calls - Bridge needs broader access for Google Messages protocol
SystemCallArchitectures = "native";
# SystemCallFilter = [
# "@system-service"
# "@network-io"
# "@file-system"
# "@process"
# "@io-event"
# # Go runtime specific syscalls
# "futex"
# "clone"
# "arch_prctl"
# "mprotect"
# "mmap"
# "munmap"
# "set_robust_list"
# "sigaltstack"
# "rt_sigreturn"
# "rt_sigprocmask"
# "sched_getaffinity"
# # Required for chown/chmod operations in ExecStartPre
# "fchownat"
# "fchmodat"
# ];
# Resource limits
MemoryMax = "256M";
CPUWeight = 50; # Lower priority than Matrix server
IOWeight = 50;
# Process security - files created as 640 (owner rw, group r, other none)
UMask = "0027";
LockPersonality = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
# Logging
StandardOutput = "journal";
StandardError = "journal";
SyslogIdentifier = "mautrix-gmessages";
};
# Add extra packages to PATH if specified
path = [ pkgs.coreutils pkgs.gnused ] ++ cfg.extraPackages;
};
# Directory permissions
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -"
"Z ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} - -"
"d /var/log/${cfg.user} 0750 ${cfg.user} ${cfg.group} -"
"Z /var/log/${cfg.user} 0750 ${cfg.user} ${cfg.group} - -"
"d /var/lib/matrix-appservices 0775 root matrix-appservices -"
];
# Open firewall port for AS API
networking.firewall.allowedTCPPorts = [ cfg.appservice.port ];
# Set registration file path for integration with Matrix server (shared location)
services.matrix-vm.mautrix-gmessages.registrationFile = "/var/lib/matrix-appservices/mautrix_gmessages_registration.yaml";
};
}

392
modules/mautrix-slack.nix Normal file
View file

@ -0,0 +1,392 @@
# mautrix-slack Matrix-Slack bridge
# Bridges Slack to Matrix via appservice
# Implementation follows mautrix-gmessages pattern for config management
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.services.mautrix-slack;
yamlFormat = pkgs.formats.yaml { };
exampleConfigPath = "${cfg.package}/example-config.yaml";
configOverrides = {
homeserver = {
address = cfg.matrix.homeserverUrl;
domain = cfg.matrix.serverName;
};
appservice = {
address = "http://${cfg.appservice.hostname}:${toString cfg.appservice.port}";
hostname = cfg.appservice.hostname;
port = cfg.appservice.port;
id = cfg.appservice.id;
bot = {
username = cfg.appservice.senderLocalpart;
displayname = cfg.appservice.botDisplayName;
avatar = cfg.appservice.botAvatar;
};
username_template = "${cfg.appservice.userPrefix}{{.}}";
};
database = {
type = cfg.database.type;
uri = cfg.database.uri;
max_open_conns = cfg.database.maxOpenConnections;
max_idle_conns = cfg.database.maxIdleConnections;
};
bridge = {
command_prefix = cfg.bridge.commandPrefix;
permissions = cfg.bridge.permissions;
};
encryption = {
allow = cfg.encryption.enable;
default = cfg.encryption.default;
require = cfg.encryption.require;
};
logging = {
min_level = cfg.logging.level;
};
};
in
{
options.services.mautrix-slack = {
enable = mkEnableOption "mautrix-slack Matrix-Slack bridge";
package = mkOption {
type = types.package;
default = pkgs.mautrix-slack;
description = "Package providing the bridge executable.";
};
user = mkOption {
type = types.str;
default = "mautrix_slack";
description = "System user for the bridge.";
};
group = mkOption {
type = types.str;
default = "mautrix_slack";
description = "Primary group for the bridge.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/mautrix_slack";
description = "State directory.";
};
extraPackages = mkOption {
type = types.listOf types.package;
default = [];
description = "Additional packages exposed to the service PATH.";
};
extraConfig = mkOption {
type = yamlFormat.type;
default = {};
description = "Optional YAML fragment merged into the config.";
};
# Matrix homeserver configuration
matrix = {
homeserverUrl = mkOption {
type = types.str;
default = "http://127.0.0.1:8008";
description = "Matrix homeserver URL for bridge connections";
};
serverName = mkOption {
type = types.str;
description = "Matrix server name for bridge users";
};
};
# Database configuration
database = {
type = mkOption {
type = types.str;
default = "postgres";
description = "Database type (sqlite3 or postgres)";
};
uri = mkOption {
type = types.str;
default = "postgresql:///mautrix_slack?host=/run/postgresql";
description = "Database connection string";
};
maxOpenConnections = mkOption {
type = types.int;
default = 32;
description = "Maximum number of open database connections";
};
maxIdleConnections = mkOption {
type = types.int;
default = 4;
description = "Maximum number of idle database connections";
};
};
# Logging configuration
logging = {
level = mkOption {
type = types.str;
default = "info";
description = "Log level (debug, info, warn, error)";
};
};
# Bridge behavior configuration
bridge = {
commandPrefix = mkOption {
type = types.str;
default = "!slack";
description = "Command prefix for bridge commands";
};
permissions = mkOption {
type = types.attrsOf types.str;
default = {
"example.com" = "user";
};
description = "Bridge permissions mapping";
};
};
# Appservice configuration
appservice = {
id = mkOption {
type = types.str;
default = "slack";
description = "Appservice ID";
};
hostname = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Hostname for the appservice";
};
port = mkOption {
type = types.port;
default = 29319;
description = "Port for the appservice API";
};
senderLocalpart = mkOption {
type = types.str;
default = "slackbot";
description = "Localpart of the bridge bot user";
};
botDisplayName = mkOption {
type = types.str;
default = "Slack Bridge Bot";
description = "Display name for the bridge bot";
};
botAvatar = mkOption {
type = types.str;
default = "";
description = "Avatar MXC URI for the bridge bot";
};
userPrefix = mkOption {
type = types.str;
default = "slack_";
description = "Prefix for Matrix users bridged from Slack";
};
};
# Encryption configuration
encryption = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable Matrix end-to-end encryption";
};
default = mkOption {
type = types.bool;
default = false;
description = "Enable encryption by default in new portals";
};
require = mkOption {
type = types.bool;
default = false;
description = "Require encryption in all portals";
};
};
};
config = mkIf cfg.enable {
# User and group
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
extraGroups = [ "matrix-appservices" ];
home = cfg.dataDir;
createHome = true;
};
users.groups.${cfg.group} = {};
users.groups.matrix-appservices = {};
# Systemd service with gmessages-style configuration
systemd.services.mautrix-slack = {
description = "mautrix-slack Matrix-Slack bridge";
after = [ "network.target" ]
++ optional (cfg.database.type == "postgres") "postgresql.service";
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
SupplementaryGroups = [ "matrix-appservices" ];
WorkingDirectory = cfg.dataDir;
# Use StateDirectory for persistent config and data management
StateDirectory = cfg.user;
StateDirectoryMode = "0750";
# Two-stage pre-start: directory setup as root, then config generation as service user
ExecStartPre = [
# Stage 1: Create config directory as root (+ prefix)
"+${pkgs.coreutils}/bin/mkdir -p /var/lib/${cfg.user}/config"
"+${pkgs.coreutils}/bin/chown -R ${cfg.user}:${cfg.group} /var/lib/${cfg.user}"
"+${pkgs.coreutils}/bin/chmod 750 /var/lib/${cfg.user}"
"+${pkgs.coreutils}/bin/chmod 750 /var/lib/${cfg.user}/config"
# Stage 2: Generate configuration using example template
"${pkgs.writeShellScript "mautrix-slack-prepare-config" ''
set -euo pipefail
CONFIG_DIR=/var/lib/${cfg.user}/config
CONFIG_PATH="$CONFIG_DIR/config.yaml"
REG_PATH="/var/lib/matrix-appservices/mautrix_slack_registration.yaml"
# Always regenerate config from example
cd "$CONFIG_DIR"
rm -f config.yaml
${cfg.package}/bin/mautrix-slack -c config.yaml -e
${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3 <<'PY'
import json
import os
import pathlib
import pwd
import grp
import yaml
CONFIG_PATH = pathlib.Path('config.yaml')
REG_PATH = pathlib.Path('/var/lib/matrix-appservices/mautrix_slack_registration.yaml')
with CONFIG_PATH.open('r', encoding='utf-8') as fh:
config = yaml.safe_load(fh)
overrides = json.loads(${lib.escapeShellArg (builtins.toJSON configOverrides)})
extra_cfg = json.loads(${lib.escapeShellArg (builtins.toJSON cfg.extraConfig)})
def deep_update(target, data):
for key, value in data.items():
if isinstance(value, dict) and value is not None:
if key not in target or not isinstance(target.get(key), dict):
target[key] = {}
deep_update(target[key], value)
else:
target[key] = value
deep_update(config, overrides)
if extra_cfg:
deep_update(config, extra_cfg)
# Handle permissions explicitly to ensure example values are replaced
perms = overrides.get('bridge', {}).get('permissions')
if perms is not None:
config.setdefault('bridge', {})['permissions'] = perms
# Read tokens from registration file if it exists
if REG_PATH.exists():
with REG_PATH.open('r', encoding='utf-8') as fh:
registration = yaml.safe_load(fh)
if 'as_token' in registration:
config.setdefault('appservice', {})['as_token'] = registration['as_token']
if 'hs_token' in registration:
config.setdefault('appservice', {})['hs_token'] = registration['hs_token']
with CONFIG_PATH.open('w', encoding='utf-8') as fh:
yaml.safe_dump(config, fh, default_flow_style=False, sort_keys=False)
uid = pwd.getpwnam('${cfg.user}').pw_uid
gid_service = grp.getgrnam('${cfg.group}').gr_gid
gid_shared = grp.getgrnam('matrix-appservices').gr_gid
os.chown(CONFIG_PATH, uid, gid_service)
os.chmod(CONFIG_PATH, 0o640)
PY
# Generate registration file if it doesn't exist
if [ ! -f "$REG_PATH" ]; then
mkdir -p $(dirname "$REG_PATH")
${cfg.package}/bin/mautrix-slack -c config.yaml -g -r "$REG_PATH"
chown ${cfg.user}:matrix-appservices "$REG_PATH"
chmod 640 "$REG_PATH"
fi
''}"
];
# Start mautrix-slack with state directory config
ExecStart = "${cfg.package}/bin/mautrix-slack -c /var/lib/${cfg.user}/config/config.yaml";
# Restart policy
Restart = "always";
RestartSec = "10s";
# Security hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
# Allow writing to data and shared appservices directory
ReadWritePaths = [
cfg.dataDir
"/var/lib/matrix-appservices"
];
# Resource limits
MemoryMax = "256M";
# Process security
UMask = "0027";
LockPersonality = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
# Logging
StandardOutput = "journal";
StandardError = "journal";
SyslogIdentifier = "mautrix-slack";
};
# Add extra packages to PATH if specified
path = [ pkgs.coreutils ] ++ cfg.extraPackages;
};
# Directory permissions
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -"
"d /var/lib/matrix-appservices 0770 root matrix-appservices -"
];
};
}

View file

@ -0,0 +1,571 @@
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.services.matrix-vm.mautrix-whatsapp;
yamlFormat = pkgs.formats.yaml { };
registrationDir = builtins.dirOf (toString cfg.appservice.registrationFile);
baseConfig = {
homeserver = {
address = cfg.matrix.homeserverUrl;
domain = cfg.matrix.serverName;
verify_ssl = cfg.matrix.verifySSL;
};
database = {
type = cfg.database.type;
uri = cfg.database.uri;
max_open_conns = cfg.database.maxOpenConnections;
max_idle_conns = cfg.database.maxIdleConnections;
};
appservice = {
address = "http://${cfg.appservice.hostname}:${toString cfg.appservice.port}";
hostname = cfg.appservice.hostname;
port = cfg.appservice.port;
id = cfg.appservice.id;
bot = {
username = cfg.appservice.senderLocalpart;
displayname = cfg.appservice.botDisplayName;
avatar = cfg.appservice.botAvatar;
};
public_address = cfg.appservice.publicAddress;
username_template = "${cfg.appservice.userPrefix}{{.}}";
protocols = cfg.appservice.protocols;
"de.sorunome.msc2409.push_ephemeral" = cfg.appservice.pushEphemeral;
};
network = {
os_name = cfg.network.osName;
browser_name = cfg.network.browserName;
direct_media_auto_request = cfg.network.directMediaAutoRequest;
initial_auto_reconnect = cfg.network.initialAutoReconnect;
history_sync = {
max_initial_conversations = cfg.network.historySync.maxInitialConversations;
request_full_sync = cfg.network.historySync.requestFullSync;
dispatch_wait = cfg.network.historySync.dispatchWait;
media_requests = {
auto_request_media = cfg.network.historySync.mediaRequests.autoRequest;
request_method = cfg.network.historySync.mediaRequests.requestMethod;
request_local_time = cfg.network.historySync.mediaRequests.requestLocalTime;
max_async_handle = cfg.network.historySync.mediaRequests.maxAsyncHandle;
};
};
};
bridge = {
command_prefix = cfg.bridge.commandPrefix;
personal_filtering_spaces = cfg.bridge.personalFilteringSpaces;
bridge_status_notices = cfg.bridge.bridgeStatusNotices;
permissions = cfg.bridge.permissions;
cleanup_on_logout = {
enabled = cfg.bridge.cleanupOnLogout != "nothing";
};
};
double_puppet = {
servers = cfg.bridge.additionalDoublePuppetServers;
allow_discovery = cfg.bridge.allowDiscovery;
secrets = {};
};
encryption = {
allow = cfg.encryption.enable;
default = cfg.encryption.default;
require = cfg.encryption.require;
allow_key_sharing = cfg.encryption.allowKeySharing;
};
analytics = {
token = null;
};
provisioning = {
shared_secret = if cfg.appservice.provisioningSharedSecretFile != null then "__credential__" else "disable";
};
};
configBaseFile = yamlFormat.generate "mautrix-whatsapp-config-base.yaml" baseConfig;
registrationBase = {
id = cfg.appservice.id;
url = "http://${cfg.appservice.hostname}:${toString cfg.appservice.port}";
sender_localpart = cfg.appservice.senderLocalpart;
rate_limited = false;
namespaces = {
users = [{
regex = "@${cfg.appservice.userPrefix}.+:${cfg.matrix.serverName}";
exclusive = true;
}];
aliases = [{
regex = "#${cfg.appservice.aliasPrefix}.*:${cfg.matrix.serverName}";
exclusive = true;
}];
rooms = [];
};
"de.sorunome.msc2409.push_ephemeral" = cfg.appservice.pushEphemeral;
};
registrationBaseFile = yamlFormat.generate "mautrix-whatsapp-registration-base.yaml" registrationBase;
extraConfigFile = optionalString (cfg.extraConfig != { })
(toString (yamlFormat.generate "mautrix-whatsapp-extra.yaml" cfg.extraConfig));
generateConfigScript = pkgs.writeShellScript "generate-mautrix-whatsapp-config" ''
set -euo pipefail
CONFIG_OUT="/run/${cfg.runtimeDirectory}/config.yaml"
REG_OUT="${cfg.appservice.registrationFile}"
# Runtime directories created by systemd.tmpfiles.d
${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3 <<'PY'
import os
import pathlib
import pwd
import grp
import yaml
base_config_path = pathlib.Path("${configBaseFile}")
config_output = pathlib.Path("/run/${cfg.runtimeDirectory}/config.yaml")
registration_base_path = pathlib.Path("${registrationBaseFile}")
registration_output = pathlib.Path("${cfg.appservice.registrationFile}")
extra_config_path = "${extraConfigFile}"
with base_config_path.open("r", encoding="utf-8") as fh:
config = yaml.safe_load(fh)
if extra_config_path:
with open(extra_config_path, "r", encoding="utf-8") as fh:
extra_cfg = yaml.safe_load(fh) or {}
def deep_merge(base, extra):
for key, value in extra.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
deep_merge(base[key], value)
else:
base[key] = value
return base
deep_merge(config, extra_cfg)
creds_dir = pathlib.Path(os.environ.get("CREDENTIALS_DIRECTORY", ""))
if not creds_dir:
raise SystemExit("CREDENTIALS_DIRECTORY not provided")
def read_required(name):
path = creds_dir / name
if not path.exists():
raise SystemExit(f"Missing credential {name}")
return path.read_text(encoding="utf-8").strip()
def read_optional(name):
path = creds_dir / name
if path.exists():
return path.read_text(encoding="utf-8").strip()
return None
as_token = read_required("as_token")
hs_token = read_required("hs_token")
login_secret = read_optional("login_shared_secret")
provisioning_secret = read_optional("provisioning_shared_secret")
config.setdefault("double_puppet", {}).setdefault("secrets", {})
if login_secret:
config["double_puppet"]["secrets"]["${cfg.matrix.serverName}"] = login_secret
if provisioning_secret:
config.setdefault("provisioning", {})["shared_secret"] = provisioning_secret
else:
config.setdefault("provisioning", {})["shared_secret"] = "disable"
with registration_base_path.open("r", encoding="utf-8") as fh:
registration = yaml.safe_load(fh)
registration["as_token"] = as_token
registration["hs_token"] = hs_token
# Add tokens to config appservice section
config.setdefault("appservice", {})["as_token"] = as_token
config.setdefault("appservice", {})["hs_token"] = hs_token
config_output.parent.mkdir(parents=True, exist_ok=True)
with config_output.open("w", encoding="utf-8") as fh:
yaml.safe_dump(config, fh, default_flow_style=False, sort_keys=False)
registration_output.parent.mkdir(parents=True, exist_ok=True)
with registration_output.open("w", encoding="utf-8") as fh:
yaml.safe_dump(registration, fh, default_flow_style=False, sort_keys=False)
svc_uid = pwd.getpwnam("${cfg.user}").pw_uid
svc_gid = grp.getgrnam("${cfg.group}").gr_gid
reg_uid = pwd.getpwnam("${cfg.appservice.registrationUser}").pw_uid
reg_gid = grp.getgrnam("${cfg.appservice.registrationGroup}").gr_gid
os.chown(config_output, svc_uid, svc_gid)
os.chmod(config_output, 0o640)
os.chown(registration_output, reg_uid, reg_gid)
os.chmod(registration_output, 0o640)
PY
'';
loadCredentials =
[
"as_token:${cfg.appservice.asTokenFile}"
"hs_token:${cfg.appservice.hsTokenFile}"
]
++ optional (cfg.matrix.loginSharedSecretFile != null)
"login_shared_secret:${cfg.matrix.loginSharedSecretFile}"
++ optional (cfg.appservice.provisioningSharedSecretFile != null)
"provisioning_shared_secret:${cfg.appservice.provisioningSharedSecretFile}";
in
{
options.services.matrix-vm.mautrix-whatsapp = {
enable = mkEnableOption "mautrix-whatsapp bridge";
package = mkOption {
type = types.package;
default = pkgs.mautrix-whatsapp;
description = "Package providing the bridge executable.";
};
user = mkOption {
type = types.str;
default = "mautrix_whatsapp";
description = "System user for the bridge.";
};
group = mkOption {
type = types.str;
default = "mautrix_whatsapp";
description = "Primary group for the bridge.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/mautrix_whatsapp";
description = "State directory.";
};
runtimeDirectory = mkOption {
type = types.str;
default = "mautrix_whatsapp";
description = "Runtime directory name under /run.";
};
extraPackages = mkOption {
type = types.listOf types.package;
default = [];
description = "Additional packages exposed to the service PATH.";
};
extraConfig = mkOption {
type = yamlFormat.type;
default = {};
description = "Optional YAML fragment merged into the config.";
};
matrix = {
homeserverUrl = mkOption {
type = types.str;
default = "http://127.0.0.1:6167";
description = "Homeserver URL (bridge -> HS).";
};
serverName = mkOption {
type = types.str;
default = "example.com";
description = "Matrix server name.";
};
verifySSL = mkOption {
type = types.bool;
default = false;
description = "Verify TLS for homeserver requests.";
};
loginSharedSecretFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Shared secret file for automatic double puppeting.";
};
};
database = {
type = mkOption {
type = types.enum [ "postgres" "sqlite3-fk-wal" ];
default = "postgres";
description = "Database backend.";
};
uri = mkOption {
type = types.str;
default = "postgresql:///mautrix_whatsapp?host=/run/postgresql";
description = "Database connection string.";
};
maxOpenConnections = mkOption {
type = types.ints.positive;
default = 10;
description = "Maximum open DB connections.";
};
maxIdleConnections = mkOption {
type = types.ints.unsigned;
default = 2;
description = "Maximum idle DB connections.";
};
};
appservice = {
id = mkOption {
type = types.str;
default = "whatsapp";
description = "Appservice ID.";
};
senderLocalpart = mkOption {
type = types.str;
default = "whatsappbot";
description = "Bot localpart.";
};
botDisplayName = mkOption {
type = types.nullOr types.str;
default = "WhatsApp bridge";
description = "Bot display name.";
};
botAvatar = mkOption {
type = types.nullOr types.str;
default = "mxc://maunium.net/NeXNQarUbrlYBiPCpprYsRqr";
description = "Bot avatar MXC URI.";
};
hostname = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Bind address for bridge HTTP listener.";
};
port = mkOption {
type = types.port;
default = 29318;
description = "Port for bridge HTTP listener.";
};
publicAddress = mkOption {
type = types.nullOr types.str;
default = null;
description = "Public URL for bridge (optional).";
};
asTokenFile = mkOption {
type = types.path;
description = "Path containing the appservice as_token.";
};
hsTokenFile = mkOption {
type = types.path;
description = "Path containing the appservice hs_token.";
};
provisioningSharedSecretFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Provisioning API shared secret file (optional).";
};
registrationFile = mkOption {
type = types.path;
default = "/var/lib/matrix-appservices/mautrix_whatsapp_registration.yaml";
description = "Generated registration file path.";
};
registrationUser = mkOption {
type = types.str;
default = cfg.user;
description = "Owner user for the registration file.";
};
registrationGroup = mkOption {
type = types.str;
default = cfg.group;
description = "Owner group for the registration file.";
};
userPrefix = mkOption {
type = types.str;
default = "whatsapp_";
description = "MXID prefix for puppets.";
};
aliasPrefix = mkOption {
type = types.str;
default = "whatsapp_";
description = "Alias prefix for portals.";
};
protocols = mkOption {
type = types.listOf types.str;
default = [];
description = "Optional MSC3079 protocol identifiers.";
};
pushEphemeral = mkOption {
type = types.bool;
default = false;
description = "Enable MSC2409 push ephemeral events.";
};
};
network = {
osName = mkOption {
type = types.str;
default = "mautrix-whatsapp";
description = "Device name shown in WhatsApp linked devices.";
};
browserName = mkOption {
type = types.str;
default = "unknown";
description = "Browser indicator shown in WhatsApp linked devices.";
};
directMediaAutoRequest = mkOption {
type = types.bool;
default = true;
description = "Automatically request expired media during backfill.";
};
initialAutoReconnect = mkOption {
type = types.bool;
default = true;
description = "Retry connection failures automatically on startup.";
};
historySync = {
maxInitialConversations = mkOption {
type = types.int;
default = -1;
description = "Chats to create on initial history sync (-1 = all).";
};
requestFullSync = mkOption {
type = types.bool;
default = false;
description = "Request a full-year history sync on login.";
};
dispatchWait = mkOption {
type = types.str;
default = "1m";
description = "Wait time for history payloads before backfill.";
};
mediaRequests = {
autoRequest = mkOption {
type = types.bool;
default = true;
description = "Automatically request expired media.";
};
requestMethod = mkOption {
type = types.enum [ "immediate" "local_time" ];
default = "immediate";
description = "Scheduling mode for automatic media requests.";
};
requestLocalTime = mkOption {
type = types.int;
default = 120;
description = "Minutes after midnight when using local_time scheduling.";
};
maxAsyncHandle = mkOption {
type = types.ints.positive;
default = 2;
description = "Maximum concurrent media requests per user.";
};
};
};
};
bridge = {
commandPrefix = mkOption {
type = types.str;
default = "!wa";
description = "Command prefix for management commands.";
};
personalFilteringSpaces = mkOption {
type = types.bool;
default = true;
description = "Create personal spaces for each login.";
};
bridgeStatusNotices = mkOption {
type = types.enum [ "none" "errors" "all" ];
default = "errors";
description = "Status notice verbosity.";
};
cleanupOnLogout = mkOption {
type = types.enum [ "nothing" "kick" "unbridge" "delete" ];
default = "nothing";
description = "Portal cleanup behaviour on logout.";
};
permissions = mkOption {
type = types.attrsOf (types.enum [ "relay" "commands" "user" "admin" ]);
default = { "*" = "relay"; };
description = "Permission mapping for users/domains.";
};
allowDiscovery = mkOption {
type = types.bool;
default = false;
description = "Allow double puppeting discovery for remote homeservers.";
};
additionalDoublePuppetServers = mkOption {
type = types.attrsOf types.str;
default = {};
description = "Extra homeservers permitted for double puppeting (domain -> client API URL).";
};
};
encryption = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable end-to-bridge encryption.";
};
default = mkOption {
type = types.bool;
default = false;
description = "Enable encryption in newly created portals.";
};
require = mkOption {
type = types.bool;
default = false;
description = "Drop plaintext messages in encrypted rooms.";
};
allowKeySharing = mkOption {
type = types.bool;
default = true;
description = "Fulfil inbound key requests.";
};
};
};
config = mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = false;
};
users.groups.${cfg.group} = {};
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -"
"d ${registrationDir} 2770 root matrix-appservices -"
"d /run/${cfg.runtimeDirectory} 0750 ${cfg.user} ${cfg.group} -"
];
systemd.services.mautrix-whatsapp = {
description = "mautrix-whatsapp Matrix bridge";
after = [ "network.target" "network-online.target" ]
++ optional (cfg.database.type == "postgres") "postgresql.service";
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = [ cfg.package ] ++ cfg.extraPackages;
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.dataDir;
# Runtime directory created by systemd.tmpfiles.d
ExecStartPre = generateConfigScript;
ExecStart = "${cfg.package}/bin/mautrix-whatsapp -c /run/${cfg.runtimeDirectory}/config.yaml";
Restart = "always";
RestartSec = "10s";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectKernelLogs = true;
ProtectClock = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
RestrictSUIDSGID = true;
RestrictRealtime = true;
LockPersonality = true;
UMask = "0002";
SystemCallArchitectures = "native";
ReadWritePaths = [ cfg.dataDir cfg.appservice.registrationFile registrationDir ];
LoadCredential = loadCredentials;
};
};
};
}

View file

@ -0,0 +1,61 @@
# Fail2ban configuration for protecting against brute force attacks
{ config, lib, ... }:
with lib;
{
options.security.fail2ban-enhanced = {
enable = mkEnableOption "enhanced fail2ban protection";
homeIP = mkOption {
type = types.nullOr types.str;
default = null;
example = "10.0.0.0/24";
description = "Home IP or network to whitelist";
};
bantime = mkOption {
type = types.str;
default = "1h";
description = "Ban duration";
};
maxretry = mkOption {
type = types.int;
default = 3;
description = "Maximum retry attempts";
};
};
config = mkIf config.security.fail2ban-enhanced.enable {
services.fail2ban = {
enable = true;
maxretry = config.security.fail2ban-enhanced.maxretry;
bantime = config.security.fail2ban-enhanced.bantime;
ignoreIP = [
"127.0.0.0/8"
"::1"
] ++ optional (config.security.fail2ban-enhanced.homeIP != null)
config.security.fail2ban-enhanced.homeIP;
jails = {
nginx-http-auth = ''
enabled = true
filter = nginx-http-auth
logpath = /var/log/nginx/access.log
maxretry = 5
bantime = 1h
findtime = 10m
'';
nginx-botsearch = ''
enabled = true
filter = nginx-botsearch
logpath = /var/log/nginx/error.log
maxretry = 2
bantime = 1h
'';
};
};
};
}

View file

@ -0,0 +1,130 @@
# SSH hardening configuration for production security
{ config, lib, ... }:
with lib;
{
options.security.ssh-hardening = {
enable = mkEnableOption "SSH security hardening";
level = mkOption {
type = types.enum [ "development" "production" "paranoid" ];
default = "production";
description = "Security level for SSH configuration";
};
allowUsers = mkOption {
type = types.listOf types.str;
default = [ "admin" ];
description = "Users allowed to SSH";
};
maxAuthTries = mkOption {
type = types.int;
default = 3;
description = "Maximum authentication attempts";
};
maxSessions = mkOption {
type = types.int;
default = 10;
description = "Maximum concurrent sessions";
};
};
config = mkIf config.security.ssh-hardening.enable {
services.openssh = {
enable = true;
settings = {
# Authentication settings
PermitRootLogin = if config.security.ssh-hardening.level == "development"
then "prohibit-password"
else "no";
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
ChallengeResponseAuthentication = false;
# Security settings
PermitEmptyPasswords = false;
UsePAM = false;
X11Forwarding = false;
AllowAgentForwarding = config.security.ssh-hardening.level == "development";
AllowTcpForwarding = config.security.ssh-hardening.level != "paranoid";
GatewayPorts = "no";
# User restrictions
AllowUsers = config.security.ssh-hardening.allowUsers;
# Protocol settings
Protocol = 2;
LogLevel = if config.security.ssh-hardening.level == "paranoid" then "VERBOSE" else "INFO";
# Timing settings
LoginGraceTime = 30;
ClientAliveInterval = 300;
ClientAliveCountMax = 2;
TCPKeepAlive = false;
# Crypto settings for security
Ciphers = [
"chacha20-poly1305@openssh.com"
"aes256-gcm@openssh.com"
"aes128-gcm@openssh.com"
"aes256-ctr"
"aes192-ctr"
"aes128-ctr"
];
Macs = [
"hmac-sha2-256-etm@openssh.com"
"hmac-sha2-512-etm@openssh.com"
"hmac-sha2-256"
"hmac-sha2-512"
];
KexAlgorithms = [
"curve25519-sha256@libssh.org"
"diffie-hellman-group16-sha512"
"diffie-hellman-group18-sha512"
"diffie-hellman-group14-sha256"
];
};
extraConfig = ''
# Rate limiting
MaxAuthTries ${toString config.security.ssh-hardening.maxAuthTries}
MaxSessions ${toString config.security.ssh-hardening.maxSessions}
MaxStartups 10:30:60
# Banner
${optionalString (config.security.ssh-hardening.level != "development") ''
Banner /etc/ssh/banner
''}
# Additional security
${optionalString (config.security.ssh-hardening.level == "paranoid") ''
StrictModes yes
IgnoreRhosts yes
HostbasedAuthentication no
PermitUserEnvironment no
Compression delayed
''}
'';
};
# Create SSH banner for non-development environments
environment.etc."ssh/banner" = mkIf (config.security.ssh-hardening.level != "development") {
text = ''
################################################################################
# NOTICE #
# #
# This system is for authorized users only. All activity may be monitored #
# and recorded. Unauthorized access is prohibited. #
# #
################################################################################
'';
mode = "0644";
};
};
}

View file

@ -0,0 +1,16 @@
# sops-nix configuration for age encryption
# Copy this file to .sops.yaml and replace with your age public key
#
# Generate an age key pair:
# age-keygen -o ~/.config/sops/age/keys.txt
# # Extract public key: age1... from the generated file
#
# Then encrypt secrets:
# sops -e -i secrets/secrets.yaml
creation_rules:
- path_regex: secrets/secrets\.yaml$
key_groups:
- age:
# Replace with your age public key (starts with age1...)
- age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

View file

@ -0,0 +1,31 @@
# Example secrets file for ops-jrz1 Matrix platform
# Copy this file to secrets.yaml and replace with your actual secrets
# Then encrypt with: sops -e -i secrets/secrets.yaml
# Matrix homeserver configuration
matrix-registration-token: "GENERATE_WITH_openssl_rand_hex_32"
# ACME/Let's Encrypt email for certificate notifications
acme-email: "admin@example.com"
# mautrix-slack bridge secrets
mautrix-slack:
app-token: "xapp-YOUR-SLACK-APP-TOKEN"
bot-token: "xoxb-YOUR-SLACK-BOT-TOKEN"
# mautrix-whatsapp bridge secrets (no long-term secrets, QR code pairing)
# Configuration is stored in bridge database after pairing
# mautrix-gmessages bridge secrets
mautrix-gmessages:
# Google Messages pairing data stored in bridge database
google-account-token: "GENERATED_AFTER_PAIRING"
# Fail2ban notification email (optional)
fail2ban-notification-email: "admin@example.com"
# PostgreSQL database passwords
postgresql:
mautrix-slack-password: "GENERATE_SECURE_PASSWORD"
mautrix-whatsapp-password: "GENERATE_SECURE_PASSWORD"
mautrix-gmessages-password: "GENERATE_SECURE_PASSWORD"