Compare commits

..
Author SHA1 Message Date
finn.markwitzandClaude Opus 5 b7ae8cfec2 feat(jupiter): add newsreader profile (RSSHub + Miniflux)
X killed guest API access and now prices reads at $0.005 each with no free
tier, so the only workable personal news reader is a scraping bridge fed by a
burner account's session cookie.

RSSHub turns X accounts, lists and keyword searches into RSS on loopback;
Miniflux is the reader, exposed on the LAN and registered with homepage.
Both secrets live outside the Nix store as root-owned EnvironmentFiles.

Note: the packaged RSSHub reads TWITTER_AUTH_TOKEN — the username/password
login that upstream docs still describe is commented out in that build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 10:21:09 +02:00
16 changed files with 118 additions and 233 deletions
+1 -3
View File
@@ -1,3 +1 @@
AGENTS.md
*.dec
keys.txt
AGENTS.md
-17
View File
@@ -1,17 +0,0 @@
keys:
- &admin_finn age1a0v442ct2qfjcdhyktn25spw02yq4fl08plrrvuradwwxzsle4mq8thln4
- &host_mibook age1n3uqtee5ymykrls6k04dznq50cwmj99hw7ny83yxjndt4y2ncf5qhjwa2p
# - &host_jupiter age1... # TODO: Phase 9, see docs/secrets.md
creation_rules:
- path_regex: ^secrets/mibook\.yaml$
key_groups:
- age:
- *admin_finn
- *host_mibook
# - path_regex: ^secrets/jupiter\.yaml$ # TODO: Phase 9
# key_groups:
# - age: [ *admin_finn, *host_jupiter ]
# - path_regex: ^secrets/common\.yaml$ # TODO: Phase 9
# key_groups:
# - age: [ *admin_finn, *host_mibook, *host_jupiter ]
-12
View File
@@ -35,7 +35,6 @@ flake.nix
│ ├── core.nix # system packages, locale, timezone
│ ├── network.nix
│ ├── nix.nix
│ ├── sops.nix # sops-nix decryption config, sops/age/ssh-to-age on PATH
│ └── users.nix
├── modules/ # custom NixOS option modules (my.profiles.*, my.hardware.*, my.services.*)
│ ├── environments/ # per-service/app profiles
@@ -44,21 +43,10 @@ flake.nix
└── machines/<name>/
├── configuration.nix # machine-specific NixOS settings
├── environments.nix # enables profiles via my.profiles.* / my.hardware.* options
├── secrets.nix # sops.secrets declared for this host
├── disks.nix
└── hardware-configuration.nix
```
### Secrets (sops-nix)
Secrets are encrypted with sops, decrypted per-host from that host's SSH host
key. `.sops.yaml` defines who can decrypt what; `secrets/<host>.yaml` holds
the encrypted values; `machines/<host>/secrets.nix` declares which secrets
that host exposes and consumes them via `config.sops.secrets.<name>.path`.
Always `git add` a secrets file before `nix build` — untracked files are
invisible to flake evaluation. Full workflow (adding a secret, adding a host,
rotating keys): see [`docs/secrets.md`](docs/secrets.md).
### Profile / Module Pattern
Every module under `modules/` follows the same structure:
-92
View File
@@ -1,92 +0,0 @@
# Secrets (sops-nix)
Secrets are encrypted with [sops](https://github.com/getsops/sops) and decrypted
at activation by [sops-nix](https://github.com/Mic92/sops-nix). Each host decrypts
with its own SSH host key (converted to an age key in-memory — no separate host
private key is ever stored on disk).
Tooling (`sops`, `age`, `ssh-to-age`) is available via `nix develop` in this repo,
and installed system-wide on every machine via `machines/core/sops.nix`.
## Adding a secret to an existing host
```
nix develop -c sops secrets/<host>.yaml
```
This opens `$EDITOR` on the decrypted content. Add your key, save, quit — sops
re-encrypts on write. Then declare it in `machines/<host>/secrets.nix`:
```nix
sops.secrets.my-secret = {
owner = "someuser"; # defaults to root
group = "someuser";
mode = "0400";
restartUnits = [ "foo.service" ]; # if a running service needs to pick it up
};
```
Consume it in the owning module as `config.sops.secrets.my-secret.path`
(resolves to `/run/secrets/my-secret`) — never inline the decrypted value into
a Nix string, since that copies it into the world-readable `/nix/store`.
Then:
```
nix build '.#nixosConfigurations.<host>.config.system.build.toplevel'
sudo nixos-rebuild test --flake '.#<host>'
# verify, then:
sudo nixos-rebuild switch --flake '.#<host>'
```
**The secrets file must be `git add`ed before `nix build`** — untracked files
are invisible to flake evaluation, and `sops.validateSopsFiles` needs it in the
Nix store.
## Adding a new host
1. On the new host: `ssh-to-age < /etc/ssh/ssh_host_ed25519_key.pub` to get its
age public key.
2. In `.sops.yaml`: add a `&host_<name>` anchor with that key, and a
`creation_rules` entry for `secrets/<name>.yaml` including both the new
host key and `*admin_finn`.
3. Create `machines/<name>/secrets.nix`, add it to that machine's
`configuration.nix` imports.
4. If the host needs to read an existing shared file, run
`sops updatekeys secrets/<file>.yaml` after step 2 so it's re-encrypted to
include the new recipient.
## Rotating keys
```
# after editing .sops.yaml (new/removed recipient):
nix develop -c sops updatekeys secrets/<file>.yaml
```
for every file the change affects. `updatekeys` requires you to already be
able to decrypt the file — this is why `*admin_finn` is a recipient on every
rule, as the standing fallback.
Reinstalling a host regenerates its SSH host key, which changes its age
identity. Any file previously encrypted to that host must be `updatekeys`'d
with the new key, or the host's next activation fails to decrypt.
## Where private keys live
- **Operator** (you): `~/.config/sops/age/keys.txt` — sops' default lookup
path, so `SOPS_AGE_KEY_FILE` never needs to be set. Never committed. Back
this up out-of-band (password manager, offline copy) — if it's lost, only
the surviving host keys can still decrypt their own files, and any file that
was only ever readable by an operator-only rule becomes permanently
unreadable.
- **Hosts**: `/etc/ssh/ssh_host_ed25519_key` — already managed by NixOS,
converted to age in-memory at activation time. Nothing extra to back up.
## Layout
```
.sops.yaml # age recipients + creation rules (which keys can decrypt which files)
secrets/<host>.yaml # per-host encrypted secrets (default sopsFile for that host)
machines/core/sops.nix # applied to every machine: decryption config, sops/age/ssh-to-age on PATH
machines/<host>/secrets.nix # per-host: which secrets exist, owner/group/mode
```
Generated
+1 -22
View File
@@ -264,28 +264,7 @@
"nixos-generators": "nixos-generators",
"nixos-hardware": "nixos-hardware",
"nixpkgs": "nixpkgs_3",
"nixpkgs-unstable": "nixpkgs-unstable",
"sops-nix": "sops-nix"
}
},
"sops-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1788337237,
"narHash": "sha256-gkSH8VUtCo6hnysNmb9DbTuDepH2t5pv+QWjP75xKAk=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "fbf759290e0cb0a98dfc813a4eb7d53ad1dacb57",
"type": "github"
},
"original": {
"owner": "Mic92",
"repo": "sops-nix",
"type": "github"
"nixpkgs-unstable": "nixpkgs-unstable"
}
}
},
-13
View File
@@ -11,10 +11,6 @@
url = "github:nix-community/nixos-generators";
inputs.nixpkgs.follows = "nixpkgs";
};
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
@@ -40,15 +36,6 @@
_module.args.pkgs = import inputs.nixpkgs {
inherit system;
};
devShells.default = pkgs.mkShell {
packages = with pkgs; [
sops
age
ssh-to-age
nixfmt
];
};
};
systems = [ "x86_64-linux" ];
-2
View File
@@ -4,7 +4,6 @@ let
nixpkgs
nixpkgs-unstable
nixos-hardware
sops-nix
#hyprland
;
nixosSystem = nixpkgs.lib.makeOverridable nixpkgs.lib.nixosSystem;
@@ -41,7 +40,6 @@ let
];
}
../modules
sops-nix.nixosModules.sops
# ../profiles
];
defaultModules = baseModules ++ customModules;
-1
View File
@@ -3,6 +3,5 @@
./modules.nix
./network.nix
./nix.nix
./sops.nix
./users.nix
]
-31
View File
@@ -1,31 +0,0 @@
# sops-nix plumbing — applied to every machine.
# See docs/secrets.md for the day-to-day workflow.
{
config,
lib,
pkgs,
...
}:
{
# Each host defaults to its own secrets/<hostname>.yaml. Override per-secret
# with sops.secrets.<name>.sopsFile for shared files.
sops.defaultSopsFile = lib.mkDefault (../../secrets + "/${config.networking.hostName}.yaml");
sops.defaultSopsFormat = "yaml";
sops.validateSopsFiles = true;
# Decrypt with the host's own SSH host key, converted to age.
# Explicit rather than inheriting the openssh-derived default, so this does
# not silently break if services.openssh is ever disabled.
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
sops.age.generateKey = false;
# Age only. The default here is the RSA host key imported as a GPG key,
# which is deprecated and an extra failure surface.
sops.gnupg.sshKeyPaths = [ ];
environment.systemPackages = with pkgs; [
sops
age
ssh-to-age
];
}
+6 -1
View File
@@ -1,5 +1,9 @@
# enabled profiles
_: {
{ config, lib, ... }:
let
secrets = config.sops.secrets;
in
{
my.profiles = {
hyprland.enable = false;
zsh.enable = true;
@@ -18,6 +22,7 @@ _: {
jellyfin.enable = true;
jellyseerr.enable = true;
immich.enable = true;
newsreader.enable = true;
development.enable = true;
home-assistant.enable = true;
+2 -1
View File
@@ -11,7 +11,6 @@
./hardware-configuration.nix
./environments.nix
./network.nix
./secrets.nix
# ./system.nix use docker here
];
@@ -22,6 +21,7 @@
useOSProber = true;
};
# Configure keymap in X11
services.xserver.xkb = {
layout = "de";
@@ -35,6 +35,7 @@
services.printing.enable = true;
nixpkgs.config.allowUnfree = true;
hardware.nvidia.prime = {
sync.enable = false;
+5 -1
View File
@@ -1,5 +1,9 @@
# enabled profiles
_: {
{ config, lib, ... }:
let
secrets = config.sops.secrets;
in
{
my.profiles = {
kde-desktop.enable = true;
zsh.enable = true;
-12
View File
@@ -1,12 +0,0 @@
# Secrets decrypted on mibook. sopsFile defaults to secrets/mibook.yaml
# via machines/core/sops.nix.
{ ... }:
{
# Pipeline smoke test — proves host-key decryption works end to end.
# Safe to delete once a real secret is in place.
sops.secrets.placeholder = {
mode = "0400";
owner = "root";
group = "root";
};
}
+1
View File
@@ -20,5 +20,6 @@
./jellyfin
./jellyseerr
./immich
./newsreader
];
}
+102
View File
@@ -0,0 +1,102 @@
# X (Twitter) news reader: RSSHub feed bridge + Miniflux reader
{
config,
lib,
...
}:
let
cfg = config.my.profiles.newsreader;
hostName = config.networking.hostName;
# RSSHub only ever talks to Miniflux on the same host, so it stays on
# loopback and out of the firewall.
rsshubPort = 1200;
in
{
options.my.profiles.newsreader = with lib; {
enable = mkEnableOption "RSSHub + Miniflux news reader";
port = mkOption {
type = types.port;
default = 8085; # 8080 is taken by aria on jupiter
description = "Port Miniflux listens on.";
};
rsshubSecretFile = mkOption {
type = types.path;
default = "/var/lib/secrets/rsshub.env";
description = ''
EnvironmentFile holding RSSHub's X session, in the form
```
TWITTER_AUTH_TOKEN=<auth_token cookie>,<optional second cookie>
```
X removed guest access, so the bridge needs a logged-in session: copy
the `auth_token` cookie from a burner account and close the tab without
logging out, since logging out invalidates it. Listing several cookies
gives RSSHub rotation headroom when one gets suspended.
Create this file by hand, root-owned and chmod 600 — it must not end up
in the Nix store.
'';
};
minifluxSecretFile = mkOption {
type = types.path;
default = "/var/lib/secrets/miniflux.env";
description = ''
EnvironmentFile holding the Miniflux admin account:
```
ADMIN_USERNAME=finn
ADMIN_PASSWORD=<at least 6 characters>
```
'';
};
};
config = lib.mkIf cfg.enable {
# Turns X accounts, lists and keyword searches into RSS. Feed URLs look
# like http://127.0.0.1:1200/twitter/user/<handle>, /twitter/list/<id> or
# /twitter/keyword/<query>.
services.rsshub = {
enable = true;
redis.enable = true;
secretFiles = [ cfg.rsshubSecretFile ];
settings = {
PORT = rsshubPort;
LISTEN_INADDR_ANY = false;
# X throttles aggressively and answers with an empty 200 rather than an
# error, so cache for an hour and keep retries low.
CACHE_EXPIRE = "3600";
REQUEST_RETRY = "3";
};
};
services.miniflux = {
enable = true;
adminCredentialsFile = cfg.minifluxSecretFile;
config = {
LISTEN_ADDR = "0.0.0.0:${toString cfg.port}";
BASE_URL = "http://${hostName}:${toString cfg.port}/";
CREATE_ADMIN = 1;
# Minutes. Matched to RSSHub's cache; polling harder just burns the
# X session for nothing.
POLLING_FREQUENCY = 60;
};
};
networking.firewall.allowedTCPPorts = [ cfg.port ];
my.homepage.services = [
{
group = "Services";
name = "Miniflux";
description = "RSS reader";
href = "http://${hostName}:${toString cfg.port}";
icon = "miniflux.png";
}
];
};
}
-25
View File
@@ -1,25 +0,0 @@
placeholder: ENC[AES256_GCM,data:tji/6di1F2GsD60TEBGAVvZR9m4jxQ8=,iv:BoBQVR735vq38iGx40VHIpk185ylf79mKmnpewcq/+o=,tag:lXNNk4/HYB0sw44KaQ2KYg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4ZUk5cjhGTlVveUFRdHdS
elpVV0pZVU54U3plYjNBai9ZZVBxTDhyRFFnCmpGWW81VGJoMnd4eWxCWnovQnFF
WXR1MVBTZ3VRUnNMUjI5Rko4NHpBdjQKLS0tIDkxYWNxTmhUS2dYTkFnTWZXaS9V
ejdtSmF2M1ZVdFNiWENMWlNraUYxVzAKdlz4xLhTKjJfFESDcym2xCOa71rXj6BE
mdeaCDhE11CAXqrpKU+j0/kwTsZRzyqlwQsfeI4ratu1K1vY66ONsw==
-----END AGE ENCRYPTED FILE-----
recipient: age1a0v442ct2qfjcdhyktn25spw02yq4fl08plrrvuradwwxzsle4mq8thln4
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYT1hRSGt6TDRSMklQNDA3
Z2lYS2pMRWxxeEtGRGJSeXRDSlY5UTFBNEZvCm5RK0huT282MXpwWjdrTHByY3Ev
bTBMczR1MzVEcnNDVUxadlREbGg3UUUKLS0tIHhydUlmRExkbmNNNkVTcW1OSnpZ
aEtLZkRTRE9GZ0I1bEN0ZEl3RjVMeDgKoCY7NyxFMS+WuBUez2Irf91Xuy9QpM5g
RTmhK6mw2wd8oUMdgPOUESNwh1xkCz4hDHPrDQnfd4sPhSAvh28jwg==
-----END AGE ENCRYPTED FILE-----
recipient: age1n3uqtee5ymykrls6k04dznq50cwmj99hw7ny83yxjndt4y2ncf5qhjwa2p
lastmodified: "2026-09-08T13:29:12Z"
mac: ENC[AES256_GCM,data:YjMMIUdfCl8dkuvtvBZTFIhLwsUjI6iSeN929O4igbwpRTyGVxgLmAMl81YYHJSyEibelT7ZBKiuVVnL2KjLvAKTj3H1O+AQrcAEJDPC9NwPJwbtAIpLl2li5060Kdj4kr8AY+UJ2kHycuS9dg6wPZTLBJcM/YCw0AoVftvzryM=,iv:GEQZF9RvW+MJlMIjn1ToD74RubWMQtCyE5LqW9KQQfA=,tag:NR6u88eBY1asQZM6f45hTw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3