feat(nixos): add pikachu homepage guest #48

Merged
alexion merged 1 commits from feat/pikachu-homepage into main 2026-09-15 20:45:17 -04:00
12 changed files with 391 additions and 14 deletions

View File

@@ -55,5 +55,7 @@
- On Pikachu, `nix copy --to ssh://pikachu` accepted a small `nix store add-file` probe, - On Pikachu, `nix copy --to ssh://pikachu` accepted a small `nix store add-file` probe,
but rejected locally built system paths that lacked trusted signatures. but rejected locally built system paths that lacked trusted signatures.
Use a sudo `nix-store --import` path for unsigned deploy closures. Use a sudo `nix-store --import` path for unsigned deploy closures.
- Public DNS entries are not managed through the NixOS configuration.
Do not describe NixOS route choices as public-DNS management.
- Do not hand-author `hardware-configuration.nix` for a host that has not been generated on that machine. - Do not hand-author `hardware-configuration.nix` for a host that has not been generated on that machine.
Generate it on the target system or live ISO, then stage it before evaluating the flake. Generate it on the target system or live ISO, then stage it before evaluating the flake.

View File

@@ -25,6 +25,14 @@ in
} }
]; ];
registry.homepage.${config.networking.hostName}.actualbudget = {
tab = "Apps";
section = "Other";
name = "Actual Budget";
description = "Finance";
icon = "actual-budget.png";
};
guests.actualbudget = { guests.actualbudget = {
interior = { interior = {
modules.services.actualbudget = { modules.services.actualbudget = {

View File

@@ -90,6 +90,15 @@ in
} }
]; ];
registry.homepage.${config.networking.hostName}.copyparty = {
tab = "Apps";
section = "Files";
name = "Copyparty";
description = "Files on ${config.networking.hostName}";
icon = "copyparty.svg";
href = lib.mkIf cfg.reverseProxy.enable "https://${cfg.reverseProxy.route.domain}/?h";
};
guests.copyparty = { guests.copyparty = {
interior.modules.services.copyparty = { interior.modules.services.copyparty = {
enable = true; enable = true;

View File

@@ -0,0 +1,46 @@
{
config,
lib,
...
}:
let
cfg = config.guests.homepage;
environmentFile = "/run/secrets/homepage.env";
in
{
config = lib.mkIf cfg.enable {
guests.homepage = {
interior.modules.services.homepage = {
enable = true;
hostName = config.networking.hostName;
inherit environmentFile;
};
mounts.${environmentFile} = {
file = config.sops.templates."homepage.env".path;
readOnly = true;
};
limits = {
memory = lib.mkDefault "1G";
cpu = lib.mkDefault "100%";
tasksMax = lib.mkDefault 256;
};
};
sops.secrets = {
"services/metadata/latitude" = { };
"services/metadata/longitude" = { };
};
sops.templates."homepage.env" = {
owner = "root";
group = "storage";
mode = "0440";
content = ''
HOMEPAGE_VAR_LATITUDE=${config.sops.placeholder."services/metadata/latitude"}
HOMEPAGE_VAR_LONGITUDE=${config.sops.placeholder."services/metadata/longitude"}
'';
};
};
}

View File

@@ -48,6 +48,14 @@ in
} }
]; ];
registry.homepage.${config.networking.hostName}.navidrome = {
tab = "Apps";
section = "Media";
name = "Navidrome";
description = "Music";
icon = "navidrome.png";
};
guests.navidrome = { guests.navidrome = {
interior.modules.services.navidrome.enable = true; interior.modules.services.navidrome.enable = true;

View File

@@ -144,6 +144,19 @@
address = "10.23.20.109"; address = "10.23.20.109";
}; };
guests.homepage = {
enable = true;
vlan = 20;
address = "10.23.20.110";
reverseProxy = {
enable = true;
route = {
domain = "home";
http.port = 8082;
};
};
};
guests.copyparty = { guests.copyparty = {
enable = true; enable = true;
vlan = 20; vlan = 20;

View File

@@ -0,0 +1,219 @@
{
config,
lib,
registry,
...
}:
let
cfg = config.modules.services.homepage;
mkRegistryPath = registryName: host: entry: "registry.${registryName}.${host}.${entry}";
homepageEntries = lib.concatLists (
lib.mapAttrsToList (
host: entries:
lib.mapAttrsToList (entry: homepage: {
inherit host entry homepage;
composite = registry.join host entry;
}) entries
) registry.homepage
);
mkHref = item:
if item.homepage.href != null then
item.homepage.href
else if (item.composite.route or null) != null then
if item.composite.route.port != null then
"https://${item.composite.route.domain}"
else if item.composite.route.http.port != null then
"http://${item.composite.route.domain}"
else
throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching TCP route entry"
else
throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching route entry";
mkServiceAttrs = item:
lib.filterAttrs (_: value: value != null) {
href = mkHref item;
inherit (item.homepage) description icon;
};
sortedEntries = lib.sort (left: right: left.homepage.name < right.homepage.name) homepageEntries;
insertEntry = acc: item:
acc
// {
${item.homepage.tab} = (acc.${item.homepage.tab} or { })
// {
${item.homepage.section} = (acc.${item.homepage.tab}.${item.homepage.section} or [ ]) ++ [
{ ${item.homepage.name} = mkServiceAttrs item; }
];
};
};
entriesByTab = lib.foldl' insertEntry { } sortedEntries;
tabOrder = [
"Apps"
"Download"
"System"
];
orderedTabs = tabOrder ++ lib.filter (tab: !lib.elem tab tabOrder) (lib.attrNames entriesByTab);
sectionOrderByTab = {
Apps = [
"Media"
"Files"
"Other"
];
Download = [
"Library Managers"
"Other Utilities"
"Download Clients"
];
System = [
"Monitoring"
"Network"
"Machines"
"Docker"
];
};
orderedSections = tab: sections:
let
sectionOrder = sectionOrderByTab.${tab} or [ ];
in
sectionOrder ++ lib.filter (section: !lib.elem section sectionOrder) (lib.attrNames sections);
mkTab = tab:
let
sections = entriesByTab.${tab};
in
{
${tab} = map (section: { ${section} = sections.${section}; }) (orderedSections tab sections);
};
homepageServices = map mkTab (lib.filter (tab: lib.hasAttr tab entriesByTab) orderedTabs);
selfEntry = registry.join cfg.hostName cfg.entryName;
selfAddress = selfEntry.address or null;
selfRoute = selfEntry.route or null;
derivedAllowedHosts = lib.unique (
lib.optional (selfRoute != null) selfRoute.domain
++ lib.optionals (selfAddress != null) [
selfAddress
"${selfAddress}:${toString cfg.listenPort}"
]
);
in
{
options.modules.services.homepage = {
enable = lib.mkEnableOption "Homepage Dashboard";
listenPort = lib.mkOption {
type = lib.types.port;
default = 8082;
description = "Port for Homepage to bind to.";
};
hostName = lib.mkOption {
type = lib.types.str;
description = "Host registry key that owns the Homepage guest entry.";
};
entryName = lib.mkOption {
type = lib.types.str;
default = "homepage";
description = "Registry entry name for this Homepage service.";
};
allowedHosts = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
description = "Host headers accepted by Homepage. Defaults to matching address and route registry entries.";
};
environmentFile = lib.mkOption {
type = lib.types.path;
default = "/run/secrets/homepage.env";
description = "Environment file passed to Homepage.";
};
};
config = lib.mkIf cfg.enable {
services.homepage-dashboard = {
enable = true;
openFirewall = true;
inherit (cfg) listenPort;
environmentFiles = [ cfg.environmentFile ];
allowedHosts = lib.concatStringsSep "," (if cfg.allowedHosts == null then derivedAllowedHosts else cfg.allowedHosts);
services = homepageServices;
widgets = [
{
greeting = {
text_size = "xl";
text = "tracion";
};
}
{
openmeteo = {
units = "metric";
latitude = "{{HOMEPAGE_VAR_LATITUDE}}";
longitude = "{{HOMEPAGE_VAR_LONGITUDE}}";
cache = 5;
};
}
{
datetime = {
text_size = "xl";
locale = "en";
format = {
dateStyle = "full";
timeStyle = "medium";
hour12 = false;
};
};
}
];
settings = {
title = "Tracion";
theme = "dark";
color = "slate";
showStats = false;
hideVersion = true;
bookmarksStyle = "icons";
useEqualHeights = true;
layout = {
Apps = {
style = "row";
columns = 1;
tab = "Apps";
Media = {
style = "row";
columns = 2;
};
Files = {
style = "row";
columns = 2;
};
Other = {
style = "row";
columns = 3;
iconsOnly = true;
};
};
Download = {
style = "row";
columns = 1;
tab = "Download";
};
System = {
tab = "System";
};
};
};
};
};
}

View File

@@ -9,33 +9,47 @@ let
mkRegistryPath = registryName: host: entry: "registry.${registryName}.${host}.${entry}"; mkRegistryPath = registryName: host: entry: "registry.${registryName}.${host}.${entry}";
mkBackend = address: route: "http://${address}:${toString route.port}"; mkBackend = address: port: "http://${address}:${toString port}";
mkVirtualHost = host: entry: mkVirtualHost = host: entry: mode:
let let
compositeEntry = registry.join host entry; compositeEntry = registry.join host entry;
route = compositeEntry.route; route = compositeEntry.route;
address = compositeEntry.address or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}"); address = compositeEntry.address or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
isHttp = mode == "http";
port = if isHttp then route.http.port else route.port;
in in
{ {
inherit host entry; inherit host entry;
registryName = "route";
name = route.domain; name = route.domain;
value = { value = {
forceSSL = true; forceSSL = !isHttp;
enableACME = true; enableACME = !isHttp;
locations."/".proxyPass = mkBackend address route; locations."/".proxyPass = mkBackend address port;
}; };
}; };
generatedVirtualHostList = lib.concatLists ( generatedHttpsVirtualHostList = lib.concatLists (
lib.mapAttrsToList ( lib.mapAttrsToList (
host: routes: host: routes:
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry) ( lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "https") (
lib.filterAttrs (_entry: route: route.port != null) routes lib.filterAttrs (_entry: route: route.port != null) routes
) )
) registry.route ) registry.route
); );
generatedHttpVirtualHostList = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "http") (
lib.filterAttrs (_entry: route: route.http.port != null) routes
)
) registry.route
);
generatedVirtualHostList = generatedHttpsVirtualHostList ++ generatedHttpVirtualHostList;
virtualHostsByDomain = lib.foldl' ( virtualHostsByDomain = lib.foldl' (
acc: virtualHost: acc: virtualHost:
acc acc
@@ -49,7 +63,7 @@ let
domain: virtualHosts: domain: virtualHosts:
if lib.length virtualHosts > 1 then if lib.length virtualHosts > 1 then
[ [
"duplicate route domain ${domain} in ${lib.concatMapStringsSep " and " (virtualHost: mkRegistryPath "route" virtualHost.host virtualHost.entry) virtualHosts}" "duplicate route domain ${domain} in ${lib.concatMapStringsSep " and " (virtualHost: mkRegistryPath virtualHost.registryName virtualHost.host virtualHost.entry) virtualHosts}"
] ]
else else
[ ] [ ]

View File

@@ -0,0 +1,45 @@
{ lib, ... }:
{
type = lib.types.submodule {
options = {
tab = lib.mkOption {
type = lib.types.str;
example = "Apps";
description = "Homepage tab that contains this entry.";
};
section = lib.mkOption {
type = lib.types.str;
example = "Media";
description = "Homepage section that contains this entry.";
};
name = lib.mkOption {
type = lib.types.str;
example = "Navidrome";
description = "Homepage display name for this entry.";
};
description = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "Music";
description = "Homepage description for this entry.";
};
icon = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "navidrome.png";
description = "Homepage icon for this entry.";
};
href = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "https://music.alexion.dev";
description = "Homepage link override for this entry.";
};
};
};
}

View File

@@ -12,6 +12,10 @@
); );
message = "Route UDP ports must be unique."; message = "Route UDP ports must be unique.";
} }
{
assertion = entries: lib.all (entry: entry.value.port == null || entry.value.http.port == null) entries;
message = "Route entries must not set both port and http.port.";
}
]; ];
type = lib.types.submodule { type = lib.types.submodule {
@@ -19,14 +23,21 @@
domain = lib.mkOption { domain = lib.mkOption {
type = lib.types.str; type = lib.types.str;
example = "files.alexion.dev"; example = "files.alexion.dev";
description = "Public domain served by this route entry."; description = "Domain served by this route entry.";
}; };
port = lib.mkOption { port = lib.mkOption {
type = lib.types.nullOr lib.types.port; type = lib.types.nullOr lib.types.port;
default = null; default = null;
example = 3923; example = 3923;
description = "Backend TCP port served by the matching address registry entry."; description = "HTTPS backend TCP port served by the matching address registry entry.";
};
http.port = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = null;
example = 8082;
description = "HTTP backend TCP port served by the matching address registry entry.";
}; };
udp.ports = lib.mkOption { udp.ports = lib.mkOption {

View File

@@ -1,5 +1,8 @@
alexion-password: ENC[AES256_GCM,data:EGTsJvW4/zA+FfhUtZD4E6HIsI0JQ3LLL511XjI9hDl3NaHNe4g+wWy7P5DQnW29G5W4TaPZIetUcV7OO+1btuRxTuwbWVOulA==,iv:Ihf1kz5DFtvy1bUnfQGAU0/BQrwUvqKwqnK1JbKnWHU=,tag:K0N/6H/IWesNMxzXhQQO/w==,type:str] alexion-password: ENC[AES256_GCM,data:EGTsJvW4/zA+FfhUtZD4E6HIsI0JQ3LLL511XjI9hDl3NaHNe4g+wWy7P5DQnW29G5W4TaPZIetUcV7OO+1btuRxTuwbWVOulA==,iv:Ihf1kz5DFtvy1bUnfQGAU0/BQrwUvqKwqnK1JbKnWHU=,tag:K0N/6H/IWesNMxzXhQQO/w==,type:str]
services: services:
metadata:
latitude: ENC[AES256_GCM,data:AB6HQjDVghSv,iv:lDTrdg5Bc0xCygOLPlDHrm/eFoHW9rmZ35CEcVXqusg=,tag:BOTXwWYNL7erD9aH6x3wGQ==,type:str]
longitude: ENC[AES256_GCM,data:+iOmY92f7QQ+Ew==,iv:7fSpaHTSB1l9EbhRR+GrErmuZxfTxeEKy5rEWW6Th0I=,tag:zFtou1itlH4Iec8obgXV6g==,type:str]
copyparty: copyparty:
users: users:
alexion: alexion:
@@ -46,7 +49,7 @@ sops:
28uWUpKzzgErq1aEbKNgh4cU5TH2RlAHjtzRC3ARUu9hlW6lnB4pSw== 28uWUpKzzgErq1aEbKNgh4cU5TH2RlAHjtzRC3ARUu9hlW6lnB4pSw==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
lastmodified: "2026-09-10T18:32:33Z" lastmodified: "2026-09-16T00:35:15Z"
mac: ENC[AES256_GCM,data:g3xIylSvyn5Z0JBA8siFmMrZxoM3a3U5c+f9qFmb3zXDLF03/feCUlvG2jjB7P2N9phIGSIEOPk1dzZDBMwv6D2hK6MppupV+MIe9diIt0gGARIOJNphrxLDxMHtwcWg6Z20D6tbPl0ECtLI0K9GOtwvI0OXeyYzu5VgFaFlZHw=,iv:TAUVJRXFTKNVA0ORMD7x56WVGjG+zimj3iqEn7urh64=,tag:SeOOV9ZUY4x5Ns0A16F1uw==,type:str] mac: ENC[AES256_GCM,data:VdYFSoulJMU0QjfzdBUVnXHruLbMkyYA4mirYwl2y4RijyfSBjtgv+bn+p6PiSQSAkpe7kQ8qJhfZVVm0MtgJvaRm3r3fj2lMUH930UhyMCK2F8uypBychwZJvn+Jo3m1rBOJTicd0qQvSA9heVBxQ+TnVGCwhnQWWFj/GsIV5s=,iv:kr7LtcDdT7VVV78qIBtFZYLSoq2x0AOltlKjCUMxPuM=,tag:+MXD2DbI34gv8gpaDhobmg==,type:str]
unencrypted_suffix: _unencrypted unencrypted_suffix: _unencrypted
version: 3.13.3 version: 3.13.3

View File

@@ -50,10 +50,9 @@
- immich - immich
- romm - romm
- gitea - gitea
- vikunja (?)
- nginxproxymanager (gradual migration to nginx guest) - nginxproxymanager (gradual migration to nginx guest)
- adguard - adguard
- homepage - manage DNS rewrites for local service names such as `home`
## raichu proxmox -> nixos ## raichu proxmox -> nixos
- dwn torrent (vpn) - dwn torrent (vpn)