Commit 9e27e910cdee
Changed files (8)
modules
wallabag
secrets
systems
modules/wallabag/default.nix
@@ -0,0 +1,379 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+
+with lib;
+
+let
+ cfg = config.services.wallabag;
+
+ defaultUser = "wallabag";
+ defaultGroup = "wallabag";
+
+ # Generate parameters.yml for Symfony
+ parametersYml = pkgs.writeText "wallabag-parameters.yml" ''
+ parameters:
+ database_driver: pdo_pgsql
+ database_host: ${cfg.database.host}
+ database_port: ${toString cfg.database.port}
+ database_name: ${cfg.database.name}
+ database_user: ${cfg.database.user}
+ database_password: ~
+ database_path: null
+ database_table_prefix: wallabag_
+ database_socket: null
+ database_charset: utf8
+
+ domain_name: '${cfg.domainName}'
+ server_name: '${cfg.serverName}'
+
+ mailer_dsn: '${cfg.mailerDsn}'
+ locale: ${cfg.locale}
+ secret: '%env(WALLABAG_SECRET)%'
+
+ twofactor_sender: ${cfg.twofactorSender}
+ fosuser_registration: ${boolToString cfg.registration}
+ fosuser_confirmation: ${boolToString cfg.registrationConfirmation}
+
+ fos_oauth_server_access_token_lifetime: 3600
+ fos_oauth_server_refresh_token_lifetime: 1209600
+
+ from_email: ${cfg.fromEmail}
+ rss_limit: 50
+
+ rabbitmq_host: localhost
+ rabbitmq_port: 5672
+ rabbitmq_user: guest
+ rabbitmq_password: guest
+ rabbitmq_prefetch_count: 10
+
+ redis_scheme: tcp
+ redis_host: localhost
+ redis_port: 6379
+ redis_path: null
+ redis_password: null
+
+ sentry_dsn: null
+ '';
+
+ # Setup script that initializes the data directory and runs migrations
+ wallabag-setup = pkgs.writeShellScript "wallabag-setup" ''
+ set -euo pipefail
+
+ DATA_DIR="${cfg.dataDir}"
+ PACKAGE="${cfg.package}"
+
+ # Ensure data directory structure
+ mkdir -p "$DATA_DIR/app/config"
+ mkdir -p "$DATA_DIR/var/cache/prod"
+ mkdir -p "$DATA_DIR/var/logs"
+ mkdir -p "$DATA_DIR/var/sessions"
+ mkdir -p "$DATA_DIR/web/uploads"
+ mkdir -p "$DATA_DIR/data/db"
+ mkdir -p "$DATA_DIR/data/assets"
+
+ # Copy parameters.yml
+ cp ${parametersYml} "$DATA_DIR/app/config/parameters.yml"
+
+ # Read database password from file if provided
+ DB_PASS=""
+ if [ -n "${toString cfg.database.passwordFile}" ] && [ -f "${toString cfg.database.passwordFile}" ]; then
+ DB_PASS=$(cat "${toString cfg.database.passwordFile}")
+ fi
+
+ # Read secret from file
+ WALLABAG_SECRET_VALUE=""
+ if [ -f "${toString cfg.secretKeyFile}" ]; then
+ WALLABAG_SECRET_VALUE=$(cat "${toString cfg.secretKeyFile}")
+ else
+ echo "ERROR: Secret key file not found: ${toString cfg.secretKeyFile}"
+ exit 1
+ fi
+
+ # Patch password into parameters.yml if using password auth
+ if [ -n "$DB_PASS" ]; then
+ ${pkgs.gnused}/bin/sed -i "s|database_password: ~|database_password: '$DB_PASS'|" \
+ "$DATA_DIR/app/config/parameters.yml"
+ fi
+
+ export WALLABAG_DATA="$DATA_DIR"
+ export WALLABAG_SECRET="$WALLABAG_SECRET_VALUE"
+
+ # Clear cache on every start (required after upgrades)
+ rm -rf "$DATA_DIR/var/cache/prod/"*
+
+ # Run database migrations
+ ${cfg.package}/bin/console doctrine:migrations:migrate --no-interaction --env=prod || true
+
+ # Install craue settings if first run
+ ${cfg.package}/bin/console wallabag:install --env=prod --no-interaction --reset=no 2>/dev/null || true
+ '';
+
+ phpPackage = pkgs.php83.withExtensions (
+ { enabled, all }:
+ enabled
+ ++ [
+ all.pdo_pgsql
+ all.pgsql
+ all.intl
+ all.gd
+ all.bcmath
+ all.tidy
+ all.imagick
+ ]
+ );
+
+in
+{
+ options.services.wallabag = {
+ enable = mkEnableOption "wallabag read-it-later service";
+
+ package = mkPackageOption pkgs "wallabag" { };
+
+ user = mkOption {
+ type = types.str;
+ default = defaultUser;
+ description = "User to run wallabag as";
+ };
+
+ group = mkOption {
+ type = types.str;
+ default = defaultGroup;
+ description = "Group to run wallabag as";
+ };
+
+ dataDir = mkOption {
+ type = types.path;
+ default = "/var/lib/wallabag";
+ description = "Data directory for wallabag";
+ };
+
+ domainName = mkOption {
+ type = types.str;
+ example = "https://wallabag.example.com";
+ description = "The full URL of your wallabag instance";
+ };
+
+ serverName = mkOption {
+ type = types.str;
+ default = "wallabag";
+ description = "Display name for the wallabag instance";
+ };
+
+ locale = mkOption {
+ type = types.str;
+ default = "en";
+ description = "Default locale";
+ };
+
+ mailerDsn = mkOption {
+ type = types.str;
+ default = "smtp://127.0.0.1";
+ description = "Mailer DSN for sending emails";
+ };
+
+ fromEmail = mkOption {
+ type = types.str;
+ default = "no-reply@wallabag.org";
+ description = "From email address";
+ };
+
+ twofactorSender = mkOption {
+ type = types.str;
+ default = "no-reply@wallabag.org";
+ description = "Two-factor authentication sender email";
+ };
+
+ registration = mkOption {
+ type = types.bool;
+ default = false;
+ description = "Allow user registration";
+ };
+
+ registrationConfirmation = mkOption {
+ type = types.bool;
+ default = false;
+ description = "Require email confirmation for registration";
+ };
+
+ secretKeyFile = mkOption {
+ type = types.path;
+ description = "Path to file containing the Symfony secret key";
+ };
+
+ port = mkOption {
+ type = types.port;
+ default = 8084;
+ description = "Port for the wallabag PHP-FPM FastCGI server (used with a reverse proxy)";
+ };
+
+ database = {
+ createLocally = mkOption {
+ type = types.bool;
+ default = true;
+ description = "Whether to create the PostgreSQL database locally";
+ };
+
+ host = mkOption {
+ type = types.str;
+ default = "/run/postgresql";
+ description = "Database host (use socket path for local peer auth)";
+ };
+
+ port = mkOption {
+ type = types.port;
+ default = 5432;
+ description = "Database port";
+ };
+
+ name = mkOption {
+ type = types.str;
+ default = "wallabag";
+ description = "Database name";
+ };
+
+ user = mkOption {
+ type = types.str;
+ default = "wallabag";
+ description = "Database user";
+ };
+
+ passwordFile = mkOption {
+ type = types.nullOr types.path;
+ default = null;
+ description = "Path to file containing the database password (null for peer auth)";
+ };
+ };
+
+ poolConfig = mkOption {
+ type = types.attrsOf (
+ types.oneOf [
+ types.str
+ types.int
+ types.bool
+ ]
+ );
+ default = { };
+ description = "Additional PHP-FPM pool configuration";
+ };
+ };
+
+ config = mkIf cfg.enable {
+ services.postgresql = mkIf cfg.database.createLocally {
+ enable = true;
+ ensureDatabases = [ cfg.database.name ];
+ ensureUsers = [
+ {
+ name = cfg.database.user;
+ ensureDBOwnership = true;
+ }
+ ];
+ };
+
+ # PHP-FPM pool for wallabag
+ services.phpfpm.pools.wallabag = {
+ user = cfg.user;
+ group = cfg.group;
+ phpPackage = phpPackage;
+ phpOptions = ''
+ log_errors = on
+ post_max_size = 20M
+ upload_max_filesize = 20M
+ memory_limit = 256M
+ '';
+ phpEnv = {
+ WALLABAG_DATA = cfg.dataDir;
+ };
+ settings = {
+ "listen.mode" = mkDefault "0660";
+ "listen.owner" = mkDefault cfg.user;
+ "listen.group" = mkDefault cfg.group;
+ "pm" = mkDefault "dynamic";
+ "pm.max_children" = mkDefault 10;
+ "pm.start_servers" = mkDefault 2;
+ "pm.min_spare_servers" = mkDefault 1;
+ "pm.max_spare_servers" = mkDefault 4;
+ "pm.max_requests" = mkDefault 500;
+ }
+ // cfg.poolConfig;
+ };
+
+ # Caddy as lightweight reverse proxy (PHP-FPM → HTTP)
+ services.caddy = {
+ enable = true;
+ virtualHosts.":${toString cfg.port}" = {
+ extraConfig = ''
+ root * ${cfg.package}/web
+ php_fastcgi unix/${config.services.phpfpm.pools.wallabag.socket} {
+ env WALLABAG_DATA ${cfg.dataDir}
+ }
+ file_server
+ '';
+ };
+ };
+
+ # Setup service that runs before PHP-FPM
+ systemd.services.wallabag-setup = {
+ description = "Wallabag setup (migrations, cache clear)";
+ after = [ "postgresql.service" ];
+ requires = mkIf cfg.database.createLocally [ "postgresql.service" ];
+ requiredBy = [ "phpfpm-wallabag.service" ];
+ before = [ "phpfpm-wallabag.service" ];
+ serviceConfig = {
+ Type = "oneshot";
+ ExecStart = wallabag-setup;
+ User = cfg.user;
+ Group = cfg.group;
+ RemainAfterExit = true;
+ ReadWritePaths = [ cfg.dataDir ];
+ WorkingDirectory = cfg.package;
+ };
+ restartTriggers = [
+ cfg.package
+ parametersYml
+ ];
+ };
+
+ # Create data directories
+ systemd.tmpfiles.settings."10-wallabag" =
+ let
+ defaultConfig = {
+ user = cfg.user;
+ group = cfg.group;
+ mode = "0750";
+ };
+ in
+ {
+ "${cfg.dataDir}".d = defaultConfig;
+ "${cfg.dataDir}/app".d = defaultConfig;
+ "${cfg.dataDir}/app/config".d = defaultConfig;
+ "${cfg.dataDir}/var".d = defaultConfig;
+ "${cfg.dataDir}/var/cache".d = defaultConfig;
+ "${cfg.dataDir}/var/logs".d = defaultConfig;
+ "${cfg.dataDir}/var/sessions".d = defaultConfig;
+ "${cfg.dataDir}/web".d = defaultConfig;
+ "${cfg.dataDir}/web/uploads".d = defaultConfig;
+ "${cfg.dataDir}/data".d = defaultConfig;
+ "${cfg.dataDir}/data/db".d = defaultConfig;
+ "${cfg.dataDir}/data/assets".d = defaultConfig;
+ };
+
+ users.users = mkIf (cfg.user == defaultUser) {
+ wallabag = {
+ inherit (cfg) group;
+ isSystemUser = true;
+ home = cfg.dataDir;
+ };
+ };
+
+ users.groups = mkIf (cfg.group == defaultGroup) {
+ wallabag = { };
+ };
+
+ # Open firewall port
+ networking.firewall.allowedTCPPorts = [ cfg.port ];
+ };
+}
secrets/aion/miniflux-admin-credentials.age
@@ -0,0 +1,9 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA A4/TNf5rEQJAsAjNb9xPKLvIGJU4lXzUZitnqH/auQLJ
+LcWWyoBp3YRbMv7kzjLfNEejmiouB6T8gxuA6M+tk/g
+-> piv-p256 cUinNw A3zp4VpPRSn3YrhHyNPXZvuGMqXUGTUnRQNtZFAtyj/e
+T5ESiDZBuRhFPw8+CAuRmLZ0pR8JOUe8q9y4CxyVJL0
+-> ssh-ed25519 5bXRbA qgcjtKYFVkKwT/ms9hJ+xhNmk/m3v3p75X3a0+ZIf3s
+2p+/yBVu4Xv4palIGru6rtsiDwEfE0MxCA84k1gP5Qw
+--- sQolRs7IWIIJRW38pE3F7xWgqB/zamTujgqV8iQy50w
+�P���h�����$���� �����m����}��/��{Yi0��,�����Ʉ������P�GL>�XaN�ĉ�ӵ���e���a�&[(��^g�ʚ��2�g
\ No newline at end of file
secrets/aion/wallabag-secret-key.age
@@ -0,0 +1,10 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA A1R/xshyWy0eO0lTHAhHxF6z7QISDWzMuEMHbf8sPTka
+rcgmUKo/0bDrwHpdpkuwT8nIu9vMx3Vlodr95GLPQBY
+-> piv-p256 cUinNw A4Dtyl/i/kasJyY1nxC9B682Zl2qAPUTTGlT96HUNua+
+kWIyFGDw1lwdVg105SWtyiPHEWg0XANV76eizr+rG2o
+-> ssh-ed25519 5bXRbA FWCz4Z8KjCAQQrz+5T6eyUqsGPQlMcRxaltkqY6B93E
+m0wPKl89FVm0Xdkz42weLN8YOFwJk2pQ5N5cFcJy2d8
+--- Y3vWqSjooMNJS+3liZpIY1UVUJikMZGbKeoLG3MZEJk
+��Ҹe�;v�Bg�GC���
+ҙ�R�3Օ T�]�/�W���!<4[?6�"����oHz<���mY���7{0OCz����*O�0�jE��Ի�ăi|9Y�
\ No newline at end of file
systems/aion/extra.nix
@@ -50,6 +50,7 @@ in
../../modules/music-playlist-dl
../../modules/harmonia
../../modules/xmpp-research-bot
+ ../../modules/wallabag
./xmpp.nix
];
@@ -93,6 +94,16 @@ in
owner = "root";
group = "root";
};
+ "miniflux-admin-credentials" = {
+ file = ../../secrets/aion/miniflux-admin-credentials.age;
+ mode = "400";
+ };
+ "wallabag-secret-key" = {
+ file = ../../secrets/aion/wallabag-secret-key.age;
+ mode = "400";
+ owner = "wallabag";
+ group = "wallabag";
+ };
# TODO: Uncomment after creating secrets with agenix
# "xmpp-research-bot-password" = {
# file = ../../secrets/aion/xmpp-research-bot-password.age;
@@ -510,6 +521,27 @@ in
"d /neo/paperless/trash 0755 vincent users -"
];
+ # Wallabag - read-it-later service
+ services.wallabag = {
+ enable = true;
+ port = 8084;
+ domainName = "https://wallabag.sbr.pm";
+ serverName = "wallabag";
+ secretKeyFile = config.age.secrets."wallabag-secret-key".path;
+ database.createLocally = true;
+ };
+
+ # Miniflux - RSS feed reader
+ services.miniflux = {
+ enable = true;
+ config = {
+ LISTEN_ADDR = "0.0.0.0:8085";
+ BASE_URL = "https://miniflux.sbr.pm";
+ };
+ createDatabaseLocally = true;
+ adminCredentialsFile = config.age.secrets."miniflux-admin-credentials".path;
+ };
+
# Override prometheus-restic-exporter service to disable DynamicUser
# This is needed so the service runs as vincent and can access SSH keys
# DISABLED: Service is currently disabled due to excessive load
@@ -534,6 +566,8 @@ in
8000 # Paperless
8384 # Syncthing web UI
13378 # Audiobookshelf
+ 8084 # Wallabag (via Caddy)
+ 8085 # Miniflux
8686 # Lidarr
9000 # Node exporter
9709 # Lidarr exportarr (prometheus)
systems/common/services/linkwarden.nix
@@ -1,60 +0,0 @@
-{ pkgs, ... }:
-{
- # Linkwarden - Self-hosted collaborative bookmark manager
- # https://linkwarden.app/
- #
- # Replacement for Omnivore (which shut down in November 2024)
- # Features: Full-page preservation, reader view, annotations, AI tagging
-
- services.linkwarden = {
- enable = true;
-
- # Network configuration
- host = "0.0.0.0";
- port = 3002;
-
- # Storage
- storageLocation = "/var/lib/linkwarden";
- cacheLocation = "/var/cache/linkwarden";
-
- # Database (auto-configured PostgreSQL)
- database = {
- createLocally = true;
- name = "linkwarden";
- user = "linkwarden";
- };
-
- # Allow user registration
- enableRegistration = true;
-
- # Secret files
- # TODO: Move to agenix for production
- secretFiles.NEXTAUTH_SECRET = "${pkgs.writeText "nextauth-secret" ''
- changeme-replace-with-agenix-secret-in-production
- ''}";
-
- # Environment variables
- environment = {
- PAGINATION_TAKE_COUNT = "24";
- AUTOSCROLL_TIMEOUT = "30";
- RE_ARCHIVE_LIMIT = "5";
- # STORAGE_FOLDER is set automatically by the module
- # Disable telemetry for privacy
- NEXT_PUBLIC_DISABLE_REGISTRATION = "false";
- };
- };
-
- # Ensure PostgreSQL is configured
- services.postgresql = {
- ensureDatabases = [ "linkwarden" ];
- ensureUsers = [
- {
- name = "linkwarden";
- ensureDBOwnership = true;
- }
- ];
- };
-
- # Open firewall for local access (Traefik will proxy)
- networking.firewall.allowedTCPPorts = [ 3002 ];
-}
systems/rhea/extra.nix
@@ -348,6 +348,14 @@ in
"podcasts.sbr.pm"
];
lidarr = mkRouter "lidarr" [ "lidarr.sbr.pm" ];
+ wallabag = mkRouter "wallabag" [
+ "wallabag.sbr.pm"
+ "read.sbr.pm"
+ ];
+ miniflux = mkRouter "miniflux" [
+ "miniflux.sbr.pm"
+ "rss.sbr.pm"
+ ];
homepage = mkRouter "homepage" [ "homepage.sbr.pm" ];
# OpenCode web interface on okinawa (VPN-only)
opencode = mkRouter "opencode" [ "opencode.sbr.pm" ];
@@ -381,6 +389,8 @@ in
homepage = mkService "http://${builtins.head globals.machines.aion.net.ips}:3001";
audiobookshelf = mkService "http://${builtins.head globals.machines.aion.net.ips}:13378";
lidarr = mkService "http://${builtins.head globals.machines.aion.net.ips}:8686";
+ wallabag = mkService "http://${builtins.head globals.machines.aion.net.ips}:8084";
+ miniflux = mkService "http://${builtins.head globals.machines.aion.net.ips}:8085";
opencode = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:5555";
llm = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:8090";
reading = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:8880";
globals.nix
@@ -612,10 +612,15 @@ _: {
aliases = [ "s" ];
};
homepage.host = "rhea";
- # Linkwarden bookmark manager (runs on sakhalin, proxied via rhea/Traefik)
- linkwarden = {
+ # Wallabag read-it-later (runs on aion, proxied via rhea/Traefik)
+ wallabag = {
host = "rhea";
- aliases = [ "links" ];
+ aliases = [ "read" ];
+ };
+ # Miniflux RSS reader (runs on aion, proxied via rhea/Traefik)
+ miniflux = {
+ host = "rhea";
+ aliases = [ "rss" ];
};
# Traefik dashboard
traefik.host = "rhea";
secrets.nix
@@ -151,6 +151,9 @@ in
"secrets/sakhalin/homeassistant-prometheus-token.age".publicKeys = users ++ [ sakhalin ];
"secrets/demeter/mosquitto-homeassistant-password.age".publicKeys = users ++ [ demeter ];
"secrets/aion/restic-aix-password.age".publicKeys = users ++ [ aion ];
+ # Wallabag + Miniflux on aion
+ "secrets/aion/miniflux-admin-credentials.age".publicKeys = users ++ [ aion ];
+ "secrets/aion/wallabag-secret-key.age".publicKeys = users ++ [ aion ];
# OpenCode web on okinawa
"secrets/okinawa/opencode-password.age".publicKeys = users ++ [ okinawa ];
"secrets/okinawa/groq-api-key.age".publicKeys = users ++ [ okinawa ];