diff options
| author | Karan Jayachandra <karan.jayachandra@nxp.com> | 2026-07-17 14:34:18 +0200 |
|---|---|---|
| committer | Karan Jayachandra <karan.jayachandra@nxp.com> | 2026-07-17 14:34:18 +0200 |
| commit | 0d0788cd4ee378fba19e47a2c5d5527c196b65e0 (patch) | |
| tree | 0fbd0e2644e94e0227005947004541fd4ff4d53e | |
Initial commit
| -rw-r--r-- | .acccheckpointignore | 10 | ||||
| -rw-r--r-- | .sops.yaml | 24 | ||||
| -rw-r--r-- | README.md | 372 | ||||
| -rw-r--r-- | flake.nix | 28 | ||||
| -rw-r--r-- | hosts/eurovm/default.nix | 32 | ||||
| -rw-r--r-- | hosts/eurovm/disko.nix | 47 | ||||
| -rw-r--r-- | hosts/eurovm/hardware.nix | 32 | ||||
| -rw-r--r-- | modules/actual.nix | 31 | ||||
| -rw-r--r-- | modules/adguard.nix | 86 | ||||
| -rw-r--r-- | modules/caddy.nix | 49 | ||||
| -rw-r--r-- | modules/cgit.nix | 68 | ||||
| -rw-r--r-- | modules/common.nix | 56 | ||||
| -rw-r--r-- | modules/miniflux.nix | 33 | ||||
| -rw-r--r-- | modules/sops.nix | 36 | ||||
| -rw-r--r-- | secrets/secrets.yaml | 22 |
15 files changed, 926 insertions, 0 deletions
diff --git a/.acccheckpointignore b/.acccheckpointignore new file mode 100644 index 0000000..94f849c --- /dev/null +++ b/.acccheckpointignore @@ -0,0 +1,10 @@ +# .acccheckpointignore +# Add file or directory patterns here to exclude them from ACC checkpoint snapshots. +# Syntax is the same as .gitignore — one pattern per line. +# Lines starting with '#' are comments. +# +# Examples: +# large-dataset/ +# *.bin +# tmp/ +# secrets.env diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..e8d07b3 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,24 @@ +# sops-nix key configuration +# +# Recipients are derived from your SSH ed25519 keys via ssh-to-age. +# To get your age public keys, run on each machine: +# ssh-to-age < ~/.ssh/id_ed25519.pub +# (or: cat ~/.ssh/id_ed25519.pub | ssh-to-age) +# +# After first deploy, also add the server's host key as a recipient: +# ssh-keyscan <server-ip> | grep ed25519 | ssh-to-age +# Then re-encrypt: sops updatekeys secrets/secrets.yaml + +keys: + - &einstein age1REPLACE_WITH_OUTPUT_OF_ssh-to-age_FOR_EINSTEIN_KEY + - &galileo age1REPLACE_WITH_OUTPUT_OF_ssh-to-age_FOR_GALILEO_KEY + # Add the server host key age recipient after first deploy: + # - &eurovm age1REPLACE_WITH_SERVER_HOST_KEY_AGE + +creation_rules: + - path_regex: secrets/.*\.yaml$ + key_groups: + - age: + - *einstein + - *galileo + # - *eurovm # uncomment after first deploy diff --git a/README.md b/README.md new file mode 100644 index 0000000..420356c --- /dev/null +++ b/README.md @@ -0,0 +1,372 @@ +# karanj.com EU Server - NixOS on Hetzner Cloud + +A flake-based, modular NixOS configuration for a personal server running in Europe. + +## Services + +| URL | Service | +|-----|---------| +| https://dns.karanj.com | AdGuard Home (DNS ad-blocker + resolver) | +| https://rss.karanj.com | Miniflux (RSS/Atom reader) | +| https://budget.karanj.com | Actual Budget (personal finance) | +| https://git.karanj.com | cgit (git repository browser) | + +All services are reverse-proxied by **Caddy** with automatic TLS via Let's Encrypt. +AdGuard also listens directly on **port 53 (UDP + TCP)** for DNS. + +--- + +## Repository Layout + +``` +flake.nix top-level flake; single host "eurovm" +.sops.yaml sops-nix age recipient configuration +secrets/secrets.yaml sops-encrypted secrets (miniflux + adguard creds) +hosts/eurovm/ + default.nix host assembly, users, SSH keys + hardware.nix Hetzner Cloud virtio/qemu-guest profile + disko.nix disk partitioning for nixos-anywhere +modules/ + common.nix SSH hardening, firewall, timezone, nix settings + sops.nix sops-nix wiring (age key from SSH host key) + caddy.nix reverse proxy + HTTPS virtual hosts + adguard.nix AdGuard Home DNS + web UI + miniflux.nix Miniflux + PostgreSQL + actual.nix Actual Budget + cgit.nix cgit + fcgiwrap + git push user +``` + +--- + +## Bootstrap: First Deploy + +### 1. Create the Hetzner Cloud VM + +- Log in to https://console.hetzner.cloud +- Create a new server: **CX22** (2 vCPU / 4 GB RAM), location **Nuremberg** or **Falkenstein** +- Base image: **Debian 12** (nixos-anywhere will replace it) +- Add your SSH public keys to the Hetzner project so root access works during install +- Note the assigned **public IPv4 address** + +### 2. Create DNS records + +At your DNS provider (for karanj.com), create **A records** pointing at the server IP: + +``` +dns.karanj.com A <server-ipv4> +rss.karanj.com A <server-ipv4> +budget.karanj.com A <server-ipv4> +git.karanj.com A <server-ipv4> +``` + +Or a single wildcard: `*.karanj.com A <server-ipv4>` + +DNS must resolve **before** the first `nixos-rebuild` so Caddy can obtain TLS certificates. + +### 3. Set up sops-nix secrets + +**3a. Convert your SSH public keys to age format:** + +On your local machine (Einstein): +```bash +cat ~/.ssh/id_ed25519.pub | ssh-to-age +# -> age1xxxx... (copy this) +``` + +Repeat on Galileo (or use the public key directly): +```bash +echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH+qnLTnorv+I2rSSfGjNiCuX/W5AxoNgAdu+cTOyKzW Galileo" | ssh-to-age +# -> age1yyyy... (copy this) +``` + +**3b. Update `.sops.yaml`:** + +Replace the placeholder values: +```yaml +keys: + - &einstein age1xxxx... # output from step 3a (Einstein) + - &galileo age1yyyy... # output from step 3a (Galileo) +``` + +**3c. Generate secrets:** + +AdGuard password hash (uses Apache htpasswd bcrypt format): +```bash +# Install apache2-utils if needed: apt install apache2-utils +htpasswd -nB admin +# Enter password when prompted; copy everything AFTER "admin:" +# Example output: admin:$2y$05$abc123... +# Put only the hash part: $2y$05$abc123... +``` + +Miniflux admin password: choose any strong password. + +**3d. Fill in and encrypt secrets.yaml:** +```bash +# Edit the plaintext file first +vim secrets/secrets.yaml +# Set ADMIN_PASSWORD and password_hash to real values + +# Then encrypt it in place +sops --encrypt --in-place secrets/secrets.yaml +``` + +The file is now safe to commit. To edit it later: `sops secrets/secrets.yaml` + +### 4. Install NixOS with nixos-anywhere + +```bash +# From your local machine (requires nix with flakes enabled) +nix run github:nix-community/nixos-anywhere -- \ + --flake .#eurovm \ + root@<server-ipv4> +``` + +nixos-anywhere will: +1. Copy the flake to the server +2. Run disko to partition the disk +3. Install NixOS +4. Reboot into the new system + +### 5. Add the server host key as a sops recipient + +After the server reboots, grab its SSH host key and convert it to age: +```bash +ssh-keyscan <server-ipv4> | grep ed25519 | ssh-to-age +# -> age1zzzz... +``` + +Update `.sops.yaml` - uncomment the `eurovm` key and fill in the age value: +```yaml + - &eurovm age1zzzz... +``` + +And update the `creation_rules` section to include `*eurovm`. + +Re-encrypt secrets with the new recipient: +```bash +sops updatekeys secrets/secrets.yaml +``` + +Commit and push, then redeploy: +```bash +nixos-rebuild switch --flake .#eurovm --target-host admin@<server-ipv4> --use-remote-sudo +``` + +--- + +## Day-to-Day Operations + +### Deploy updates +```bash +nixos-rebuild switch --flake .#eurovm --target-host admin@<server-ipv4> --use-remote-sudo +``` + +### Edit secrets +```bash +sops secrets/secrets.yaml +# Save and exit; sops re-encrypts automatically +# Then redeploy to apply +``` + +### Create a new git repository +```bash +ssh git@<server-ipv4> init --bare /srv/git/myrepo.git +# On your local machine: +git remote add origin git@<server-ipv4>:/srv/git/myrepo.git +git push -u origin main +# It appears automatically at https://git.karanj.com +``` + +### Clone a public repository +```bash +git clone https://git.karanj.com/myrepo.git +``` + +--- + +## First Access - Per App + +### AdGuard Home (https://dns.karanj.com) + +The admin account is pre-created from the sops secret during deployment. + +- **Username:** `admin` +- **Password:** whatever you set as the plaintext before encrypting `secrets.yaml` + +Log in, then go to **Settings -> General** to verify DNS is working. +To use the server as your DNS resolver, point your device's DNS to `<server-ipv4>`. + +If you need to reset the password: +```bash +# On the server, edit /var/lib/AdGuardHome/AdGuardHome.yaml +# Replace the users[].password bcrypt hash with a new one +# Then: systemctl restart adguardhome +``` + +### Miniflux (https://rss.karanj.com) + +The admin account is pre-created from the sops secret on first startup. + +- **Username:** `admin` (or whatever you set as `ADMIN_USERNAME` in secrets.yaml) +- **Password:** whatever you set as `ADMIN_PASSWORD` in secrets.yaml + +After logging in, go to **Settings -> Users** to change the password or +add additional accounts. + +To reset the password from the server: +```bash +sudo -u miniflux miniflux -reset-password +``` + +### Actual Budget (https://budget.karanj.com) + +No pre-configuration needed. On first visit: + +1. Open https://budget.karanj.com in your browser +2. The app will display a **"Create server password"** prompt +3. Enter a strong password - this is what all your devices use to sync +4. Click **OK** and the app is ready to use + +To reset the password, delete `/var/lib/actual/server-files/account.json` on the +server and restart the service: `systemctl restart actual` + +### cgit (https://git.karanj.com) + +No login required - the web interface is fully public and read-only. + +- **Browse:** open https://git.karanj.com in any browser +- **Clone/Pull:** `git clone https://git.karanj.com/<repo>.git` +- **Push:** SSH only - `git push git@<server-ipv4>:/srv/git/<repo>.git` + +### Caddy / TLS + +TLS certificates are obtained automatically from Let's Encrypt on first startup +(registered to me@karanj.com). No manual action needed. Certificates auto-renew. + +Caddy logs: `journalctl -u caddy -f` + +--- + +## Next Steps + +These are the actions to take immediately after cloning this repo, in order. + +### Step 1 - Fill in `.sops.yaml` with your age keys + +On **Einstein**: +```bash +cat ~/.ssh/id_ed25519.pub | ssh-to-age +``` + +On **Galileo** (or derive from the public key directly): +```bash +echo "ssh-to-age AAAAC3NzaC1lZDI1NTE5AAAAIH+qnLTnorv+I2rSSfGjNiCuX/W5AxoNgAdu+cTOyKzW Galileo" | ssh-to-age +``` + +Paste both `age1...` values into `.sops.yaml` replacing the `REPLACE_WITH_...` placeholders. + +### Step 2 - Set real passwords in `secrets/secrets.yaml` + +Generate the AdGuard bcrypt hash: +```bash +htpasswd -nB admin +# Copy only the hash part (after "admin:") +``` + +Then edit the secrets file and fill in real values: +```bash +vim secrets/secrets.yaml +# Set ADMIN_PASSWORD (Miniflux) and password_hash (AdGuard) +``` + +Encrypt it: +```bash +sops --encrypt --in-place secrets/secrets.yaml +``` + +Commit both `.sops.yaml` and the encrypted `secrets/secrets.yaml`. + +### Step 3 - Create the Hetzner Cloud VM + +1. Go to https://console.hetzner.cloud and create a new project (or use an existing one) +2. Add both your SSH public keys to the project under **Security -> SSH Keys** +3. Create a server with the following spec: + - **Minimum:** CAX11 (2 ARM vCPU / 4 GB RAM / 40 GB disk, ~3.79 EUR/month) - all services work fine on ARM64 + - **Alternative:** CX22 (2 x86 vCPU / 4 GB RAM / 40 GB disk, ~4.35 EUR/month) - use if you need x86 compatibility later + - Location: **Nuremberg** or **Falkenstein** + - Image: **Debian 12** (nixos-anywhere replaces it) +4. Note the **public IPv4 address** + +### Step 4 - Point DNS at the server + +At your DNS registrar for karanj.com, add: +``` +dns.karanj.com A <server-ipv4> +rss.karanj.com A <server-ipv4> +budget.karanj.com A <server-ipv4> +git.karanj.com A <server-ipv4> +``` + +Wait for propagation (usually a few minutes with most registrars). +Verify: `dig dns.karanj.com +short` should return your server IP. + +### Step 5 - Install NixOS with nixos-anywhere + +Make sure you have Nix with flakes enabled locally, then: +```bash +nix run github:nix-community/nixos-anywhere -- \ + --flake .#eurovm \ + root@<server-ipv4> +``` + +The install takes 5-10 minutes. The server reboots into NixOS at the end. + +### Step 6 - Add the server host key to sops + +After the server is up: +```bash +ssh-keyscan <server-ipv4> | grep ed25519 | ssh-to-age +# -> age1zzzz... +``` + +Uncomment and fill in the `eurovm` key in `.sops.yaml`, add `*eurovm` to the +`creation_rules` age list, then re-encrypt: +```bash +sops updatekeys secrets/secrets.yaml +``` + +### Step 7 - Final deploy + +Push the updated `.sops.yaml` and re-encrypted `secrets/secrets.yaml`, then run: +```bash +nixos-rebuild switch --flake .#eurovm --target-host admin@<server-ipv4> --use-remote-sudo +``` + +### Step 8 - First login to each service + +| Service | URL | Action | +|---------|-----|--------| +| AdGuard Home | https://dns.karanj.com | Log in with `admin` + your sops password | +| Miniflux | https://rss.karanj.com | Log in with `admin` + your sops password | +| Actual Budget | https://budget.karanj.com | Set server password in browser on first visit | +| cgit | https://git.karanj.com | No login needed - public read-only | + +### Step 9 - Update `common.nix` flake URL (optional) + +If you push this repo to GitHub/Forgejo/cgit, update the `autoUpgrade.flake` line +in `modules/common.nix` and set `enable = true` to get automatic OS updates. + +--- + +## Firewall Summary + +| Port | Protocol | Purpose | +|------|----------|---------| +| 22 | TCP | SSH (admin + git push) | +| 80 | TCP | HTTP (Caddy redirects to HTTPS) | +| 443 | TCP | HTTPS (all web services) | +| 53 | TCP + UDP | DNS (AdGuard Home) | + +All other ports are closed. App-level ports (3000, 5006, 8080, 8086) are +bound to 127.0.0.1 and never exposed directly. diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1e56e60 --- /dev/null +++ b/flake.nix @@ -0,0 +1,28 @@ +{ + description = "karanj.com EU server (Hetzner Cloud)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + + disko = { + url = "github:nix-community/disko"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + sops-nix = { + url = "github:Mic92/sops-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, disko, sops-nix, ... }: { + nixosConfigurations.eurovm = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + disko.nixosModules.disko + sops-nix.nixosModules.sops + ./hosts/eurovm/default.nix + ]; + }; + }; +} diff --git a/hosts/eurovm/default.nix b/hosts/eurovm/default.nix new file mode 100644 index 0000000..6c92b67 --- /dev/null +++ b/hosts/eurovm/default.nix @@ -0,0 +1,32 @@ +{ config, pkgs, ... }: + +{ + imports = [ + ./hardware.nix + ./disko.nix + ../../modules/common.nix + ../../modules/sops.nix + ../../modules/caddy.nix + ../../modules/adguard.nix + ../../modules/miniflux.nix + ../../modules/actual.nix + ../../modules/cgit.nix + ]; + + networking.hostName = "eurovm"; + + # Admin user - authorized SSH keys for both Einstein and Galileo devices + users.users.admin = { + isNormalUser = true; + extraGroups = [ "wheel" ]; + openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAwAgL0o4NVonSG07Xu4Eai84ns4AjoZj2V7dGC9nXit karanjayachandra@Einstein.local" + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH+qnLTnorv+I2rSSfGjNiCuX/W5AxoNgAdu+cTOyKzW Galileo" + ]; + }; + + # Allow admin to use sudo + security.sudo.wheelNeedsPassword = false; + + system.stateVersion = "24.11"; +} diff --git a/hosts/eurovm/disko.nix b/hosts/eurovm/disko.nix new file mode 100644 index 0000000..d1ec85c --- /dev/null +++ b/hosts/eurovm/disko.nix @@ -0,0 +1,47 @@ +{ ... }: + +# Disk layout for nixos-anywhere + disko. +# Targets a single virtio disk (typical for Hetzner Cloud). +# GPT with a BIOS-boot partition so GRUB works without UEFI. +{ + disko.devices = { + disk.main = { + # Hetzner Cloud primary disk is /dev/sda on most instance types. + # On NVMe instances it will be /dev/nvme0n1 - update accordingly. + device = "/dev/sda"; + type = "disk"; + content = { + type = "gpt"; + partitions = { + # 1 MiB BIOS-boot partition required by GRUB on GPT disks + boot = { + size = "1M"; + type = "EF02"; # BIOS boot + priority = 1; + }; + + # 512 MiB /boot - keeps kernels/initrds out of root for clarity + ESP = { + size = "512M"; + type = "8300"; + content = { + type = "filesystem"; + format = "ext4"; + mountpoint = "/boot"; + }; + }; + + # Root partition - all remaining space + root = { + size = "100%"; + content = { + type = "filesystem"; + format = "ext4"; + mountpoint = "/"; + }; + }; + }; + }; + }; + }; +} diff --git a/hosts/eurovm/hardware.nix b/hosts/eurovm/hardware.nix new file mode 100644 index 0000000..50f768c --- /dev/null +++ b/hosts/eurovm/hardware.nix @@ -0,0 +1,32 @@ +{ modulesPath, ... }: + +{ + imports = [ (modulesPath + "/profiles/qemu-guest.nix") ]; + + # Hetzner Cloud uses virtio block devices and a BIOS boot (non-EFI for older + # instances; newer CX instances support UEFI - disko.nix uses GPT + BIOS-boot + # partition so it works on both). + boot.loader.grub = { + enable = true; + # disko will set boot.loader.grub.device via the disk configuration + }; + + boot.initrd.availableKernelModules = [ + "ata_piix" + "uhci_hcd" + "virtio_pci" + "virtio_scsi" + "sd_mod" + "sr_mod" + ]; + + boot.kernelModules = [ "kvm-intel" ]; + + # Hetzner Cloud networking: single public interface + networking = { + useDHCP = false; + interfaces.eth0.useDHCP = true; + # IPv6 is assigned via SLAAC on Hetzner + interfaces.eth0.ipv6.addresses = []; + }; +} 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/<repo>.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"; + }; + }; + }; +} diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml new file mode 100644 index 0000000..6f5cd54 --- /dev/null +++ b/secrets/secrets.yaml @@ -0,0 +1,22 @@ +# This file must be encrypted with sops before committing to git. +# +# Steps: +# 1. Fill in your age recipients in ../.sops.yaml +# 2. Run: sops --encrypt --in-place secrets/secrets.yaml +# +# After that the file will contain encrypted ciphertext. +# To edit: sops secrets/secrets.yaml + +miniflux: + # File content fed to adminCredentialsFile - must contain two lines: + # ADMIN_USERNAME=admin + # ADMIN_PASSWORD=your-strong-password-here + admin: | + ADMIN_USERNAME=admin + ADMIN_PASSWORD=CHANGE_ME + +adguard: + # Bcrypt hash of the AdGuard admin password. + # Generate with: htpasswd -nB admin + # Copy only the hash portion (the part after "admin:") + password_hash: CHANGE_ME_bcrypt_hash_here |
