feat(nixos): add pikachu homepage guest

This commit was merged in pull request #48.
This commit is contained in:
2026-09-15 20:44:56 -04:00
parent fca37666d3
commit 9a038d9821
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,
but rejected locally built system paths that lacked trusted signatures.
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.
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 = {
interior = {
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 = {
interior.modules.services.copyparty = {
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 = {
interior.modules.services.navidrome.enable = true;

View File

@@ -144,6 +144,19 @@
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 = {
enable = true;
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}";
mkBackend = address: route: "http://${address}:${toString route.port}";
mkBackend = address: port: "http://${address}:${toString port}";
mkVirtualHost = host: entry:
mkVirtualHost = host: entry: mode:
let
compositeEntry = registry.join host entry;
route = compositeEntry.route;
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
{
inherit host entry;
registryName = "route";
name = route.domain;
value = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = mkBackend address route;
forceSSL = !isHttp;
enableACME = !isHttp;
locations."/".proxyPass = mkBackend address port;
};
};
generatedVirtualHostList = lib.concatLists (
generatedHttpsVirtualHostList = lib.concatLists (
lib.mapAttrsToList (
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
)
) 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' (
acc: virtualHost:
acc
@@ -49,7 +63,7 @@ let
domain: virtualHosts:
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
[ ]

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.";
}
{
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 {
@@ -19,14 +23,21 @@
domain = lib.mkOption {
type = lib.types.str;
example = "files.alexion.dev";
description = "Public domain served by this route entry.";
description = "Domain served by this route entry.";
};
port = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = null;
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 {

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]
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:
users:
alexion:
@@ -46,7 +49,7 @@ sops:
28uWUpKzzgErq1aEbKNgh4cU5TH2RlAHjtzRC3ARUu9hlW6lnB4pSw==
-----END AGE ENCRYPTED FILE-----
recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
lastmodified: "2026-09-10T18:32:33Z"
mac: ENC[AES256_GCM,data:g3xIylSvyn5Z0JBA8siFmMrZxoM3a3U5c+f9qFmb3zXDLF03/feCUlvG2jjB7P2N9phIGSIEOPk1dzZDBMwv6D2hK6MppupV+MIe9diIt0gGARIOJNphrxLDxMHtwcWg6Z20D6tbPl0ECtLI0K9GOtwvI0OXeyYzu5VgFaFlZHw=,iv:TAUVJRXFTKNVA0ORMD7x56WVGjG+zimj3iqEn7urh64=,tag:SeOOV9ZUY4x5Ns0A16F1uw==,type:str]
lastmodified: "2026-09-16T00:35:15Z"
mac: ENC[AES256_GCM,data:VdYFSoulJMU0QjfzdBUVnXHruLbMkyYA4mirYwl2y4RijyfSBjtgv+bn+p6PiSQSAkpe7kQ8qJhfZVVm0MtgJvaRm3r3fj2lMUH930UhyMCK2F8uypBychwZJvn+Jo3m1rBOJTicd0qQvSA9heVBxQ+TnVGCwhnQWWFj/GsIV5s=,iv:kr7LtcDdT7VVV78qIBtFZYLSoq2x0AOltlKjCUMxPuM=,tag:+MXD2DbI34gv8gpaDhobmg==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3