From 0d0788cd4ee378fba19e47a2c5d5527c196b65e0 Mon Sep 17 00:00:00 2001 From: Karan Jayachandra Date: Fri, 17 Jul 2026 14:34:18 +0200 Subject: Initial commit --- modules/actual.nix | 31 +++++++++++++++++++ modules/adguard.nix | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ modules/caddy.nix | 49 ++++++++++++++++++++++++++++++ modules/cgit.nix | 68 +++++++++++++++++++++++++++++++++++++++++ modules/common.nix | 56 ++++++++++++++++++++++++++++++++++ modules/miniflux.nix | 33 ++++++++++++++++++++ modules/sops.nix | 36 ++++++++++++++++++++++ 7 files changed, 359 insertions(+) create mode 100644 modules/actual.nix create mode 100644 modules/adguard.nix create mode 100644 modules/caddy.nix create mode 100644 modules/cgit.nix create mode 100644 modules/common.nix create mode 100644 modules/miniflux.nix create mode 100644 modules/sops.nix (limited to 'modules') diff --git a/modules/actual.nix b/modules/actual.nix new file mode 100644 index 0000000..ead1459 --- /dev/null +++ b/modules/actual.nix @@ -0,0 +1,31 @@ +{ pkgs, ... }: + +# Actual Budget - local-first personal finance application. +# +# Uses the NixOS native module (services.actual) available in nixpkgs 24.11+. +# On first visit to https://budget.karanj.com the app prompts you to set a +# server password in the browser - no pre-configuration needed. +# +# All budget data is stored in /var/lib/actual (persists across reboots). +{ + services.actual = { + enable = true; + + settings = { + # Listen on localhost only; Caddy handles public HTTPS + hostname = "127.0.0.1"; + port = 5006; + + # Tell Actual its public URL so HTTPS redirects work correctly + serverFiles = "/var/lib/actual/server-files"; + userFiles = "/var/lib/actual/user-files"; + }; + }; + + # Ensure data directory exists with correct ownership + systemd.tmpfiles.rules = [ + "d /var/lib/actual 0750 actual actual -" + "d /var/lib/actual/server-files 0750 actual actual -" + "d /var/lib/actual/user-files 0750 actual actual -" + ]; +} diff --git a/modules/adguard.nix b/modules/adguard.nix new file mode 100644 index 0000000..6c49b28 --- /dev/null +++ b/modules/adguard.nix @@ -0,0 +1,86 @@ +{ config, pkgs, lib, ... }: + +# AdGuard Home - DNS resolver + optional network-wide ad blocker. +# +# DNS listens on port 53 (udp/tcp) directly on the public IP. +# The web UI listens on 127.0.0.1:3000 and is fronted by Caddy. +# +# Admin password is seeded from a sops secret containing a bcrypt hash. +# To generate the hash on your local machine: +# htpasswd -nB admin +# Copy the hash portion (everything after "admin:") into secrets.yaml. +{ + services.adguardhome = { + enable = true; + mutableSettings = false; # declarative mode - config comes from Nix only + + settings = { + http = { + address = "127.0.0.1:3000"; + }; + + dns = { + bind_hosts = [ "0.0.0.0" ]; + port = 53; + # Upstream DNS resolvers (privacy-respecting) + upstream_dns = [ + "https://dns.quad9.net/dns-query" + "https://cloudflare-dns.com/dns-query" + ]; + bootstrap_dns = [ + "9.9.9.9" + "1.1.1.1" + ]; + enable_dnssec = true; + }; + + # Users block: username "admin", password from sops secret at activation. + # The activation script below writes the hash into the config before + # AdGuard starts, because mutableSettings=false uses a static config file + # but the password hash must be injected at runtime (it contains a secret). + users = [ + { + name = "admin"; + # Placeholder - replaced at activation time by the script below + password = "REPLACED_AT_ACTIVATION"; + } + ]; + + # Basic filtering + filtering = { + enabled = true; + update_interval = 24; + }; + + # Block lists + filters = [ + { + enabled = true; + url = "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt"; + name = "AdGuard DNS filter"; + id = 1; + } + { + enabled = true; + url = "https://adaway.org/hosts.txt"; + name = "AdAway Default Blocklist"; + id = 2; + } + ]; + }; + }; + + # At activation: inject the bcrypt password hash from the sops secret into + # the AdGuard config so the declarative config has the real hash. + system.activationScripts.adguard-password = { + deps = [ "sops" ]; + text = '' + HASH_FILE="${config.sops.secrets."adguard/password_hash".path}" + CFG="/var/lib/AdGuardHome/AdGuardHome.yaml" + if [ -f "$HASH_FILE" ] && [ -f "$CFG" ]; then + HASH=$(cat "$HASH_FILE") + ${pkgs.gnused}/bin/sed -i "s|REPLACED_AT_ACTIVATION|$HASH|g" "$CFG" + fi + ''; + }; +} diff --git a/modules/caddy.nix b/modules/caddy.nix new file mode 100644 index 0000000..c2c41e4 --- /dev/null +++ b/modules/caddy.nix @@ -0,0 +1,49 @@ +{ ... }: + +# Caddy reverse proxy with automatic HTTPS (Let's Encrypt). +# All app ports are bound to 127.0.0.1; only Caddy listens on 80/443. +{ + services.caddy = { + enable = true; + email = "me@karanj.com"; + + virtualHosts = { + + # AdGuard Home web UI + "dns.karanj.com" = { + extraConfig = '' + reverse_proxy 127.0.0.1:3000 + ''; + }; + + # Miniflux RSS reader + "rss.karanj.com" = { + extraConfig = '' + reverse_proxy 127.0.0.1:8080 + ''; + }; + + # Actual Budget + "budget.karanj.com" = { + extraConfig = '' + reverse_proxy 127.0.0.1:5006 + ''; + }; + + # cgit - public read-only git viewer + smart HTTP for git clone/pull + "git.karanj.com" = { + extraConfig = '' + # Smart HTTP git (clone/pull only - no push exposed) + handle /git/* { + reverse_proxy 127.0.0.1:8085 + } + # cgit web UI + handle { + reverse_proxy 127.0.0.1:8086 + } + ''; + }; + + }; + }; +} diff --git a/modules/cgit.nix b/modules/cgit.nix new file mode 100644 index 0000000..90cf21b --- /dev/null +++ b/modules/cgit.nix @@ -0,0 +1,68 @@ +{ config, pkgs, lib, ... }: + +# cgit - fast web interface for git repositories. +# +# Repositories live in /srv/git/.git (bare repos). +# Access model: +# - Web browsing: public, no auth (https://git.karanj.com) +# - git clone/pull over HTTPS: public, read-only via git-http-backend +# - git push: SSH only, using the "git" user + your authorized keys +# +# Caddy routes: +# /git/* -> fcgiwrap serving git-http-backend (port 8085 via nginx shim) +# /* -> cgit (port 8086 via nginx shim) +# +# Both cgit and git-http-backend are served through a minimal nginx instance +# bound to localhost, which Caddy then reverse-proxies. +{ + # Dedicated git user for SSH push access + users.users.git = { + isSystemUser = true; + group = "git"; + home = "/srv/git"; + shell = pkgs.git; + # Allow pushing from both your devices + openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAwAgL0o4NVonSG07Xu4Eai84ns4AjoZj2V7dGC9nXit karanjayachandra@Einstein.local" + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH+qnLTnorv+I2rSSfGjNiCuX/W5AxoNgAdu+cTOyKzW Galileo" + ]; + }; + users.groups.git = {}; + + # Repository root + systemd.tmpfiles.rules = [ + "d /srv/git 0755 git git -" + ]; + + # cgit web interface served via nginx + fcgiwrap + services.cgit."git.karanj.com" = { + enable = true; + settings = { + # Repository root + scan-path = "/srv/git"; + + # Site branding + root-title = "karanj.com git"; + root-desc = "personal git repositories"; + + # Enable common features + enable-index-links = 1; + enable-commit-graph = 1; + enable-log-filecount = 1; + enable-log-linecount = 1; + enable-blame = 1; + enable-http-clone = 1; # show clone URL in UI + + # Public clone URL prefix shown in the cgit UI + clone-url = "https://git.karanj.com/$CGIT_REPO_URL"; + + # Syntax highlighting + source-filter = "${pkgs.cgit}/lib/cgit/filters/syntax-highlighting.py"; + about-filter = "${pkgs.cgit}/lib/cgit/filters/about-formatting.sh"; + }; + }; + + # fcgiwrap is needed to run cgit's CGI scripts; the cgit module enables it + # automatically, but we make it explicit here for clarity. + services.fcgiwrap.enable = true; +} diff --git a/modules/common.nix b/modules/common.nix new file mode 100644 index 0000000..e5c38d4 --- /dev/null +++ b/modules/common.nix @@ -0,0 +1,56 @@ +{ pkgs, ... }: + +{ + # European timezone + time.timeZone = "Europe/Amsterdam"; + i18n.defaultLocale = "en_US.UTF-8"; + + # SSH: key-only, no passwords, no root login + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = false; + PermitRootLogin = "no"; + KbdInteractiveAuthentication = false; + }; + }; + + # Firewall: only allow SSH, HTTP, HTTPS, and DNS (for AdGuard) + networking.firewall = { + enable = true; + allowedTCPPorts = [ 22 80 443 53 ]; + allowedUDPPorts = [ 53 ]; + }; + + # Nix settings: flakes, auto-gc, auto-optimise + nix = { + settings = { + experimental-features = [ "nix-command" "flakes" ]; + auto-optimise-store = true; + trusted-users = [ "root" "admin" ]; + }; + gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 14d"; + }; + }; + + # Base system packages + environment.systemPackages = with pkgs; [ + git + htop + curl + vim + age + ssh-to-age + sops + ]; + + # Automatic security updates for the OS + system.autoUpgrade = { + enable = false; # set to true once you are comfortable with unattended reboots + flake = "github:YOUR_USERNAME/nix#eurovm"; # update to your actual flake URL + flags = [ "--update-input" "nixpkgs" ]; + }; +} diff --git a/modules/miniflux.nix b/modules/miniflux.nix new file mode 100644 index 0000000..8020a75 --- /dev/null +++ b/modules/miniflux.nix @@ -0,0 +1,33 @@ +{ config, ... }: + +# Miniflux RSS/Atom reader. +# PostgreSQL is enabled automatically by the NixOS miniflux module. +# The admin user is created on first startup from the sops credentials file. +# +# Credentials file format (stored encrypted in secrets/secrets.yaml): +# ADMIN_USERNAME=admin +# ADMIN_PASSWORD=your-strong-password-here +{ + services.miniflux = { + enable = true; + + # The NixOS module reads ADMIN_USERNAME and ADMIN_PASSWORD from this file + # and sets CREATE_ADMIN=1 on first run to seed the database. + adminCredentialsFile = config.sops.secrets."miniflux/admin".path; + + config = { + # Listen only on localhost; Caddy handles public HTTPS + LISTEN_ADDR = "127.0.0.1:8080"; + BASE_URL = "https://rss.karanj.com"; + + # Log level: info in production + LOG_LEVEL = "info"; + + # Polling: fetch new articles every 15 minutes + POLLING_FREQUENCY = "15"; + + # Cleanup old read articles after 90 days + CLEANUP_ARCHIVE_READ_DAYS = "90"; + }; + }; +} diff --git a/modules/sops.nix b/modules/sops.nix new file mode 100644 index 0000000..774caad --- /dev/null +++ b/modules/sops.nix @@ -0,0 +1,36 @@ +{ config, ... }: + +# sops-nix configuration. +# Secrets are encrypted to two recipients: +# 1. Your personal age key (derived from your SSH ed25519 key via ssh-to-age) +# 2. This host's age key (derived from /etc/ssh/ssh_host_ed25519_key at boot) +# +# The host key path below is where sops-nix looks by default when +# services.openssh generates the host key. +{ + sops = { + defaultSopsFile = ../../secrets/secrets.yaml; + defaultSopsFormat = "yaml"; + + # Derive the machine's age key from its SSH ed25519 host key. + # This key is auto-generated by OpenSSH on first boot and stays stable. + age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ]; + + secrets = { + # Miniflux admin credentials file (ADMIN_USERNAME=... ADMIN_PASSWORD=...) + "miniflux/admin" = { + owner = "miniflux"; + group = "miniflux"; + mode = "0400"; + }; + + # AdGuard Home admin password bcrypt hash + # Format: plain string containing only the bcrypt hash + "adguard/password_hash" = { + owner = "adguardhome"; + group = "adguardhome"; + mode = "0400"; + }; + }; + }; +} -- cgit v1.3.1