Deployments: deploy, restart and git sync now run in the background, one at
a time per app (a newer request replaces a queued one). Each run is recorded
in SQLite with its log, streamed to the UI while it runs, and can be
cancelled. Any earlier deployment can be deployed again, which rolls back to
its commit, or to its saved compose file for compose apps.
Auto deploy: POST /hooks/<app>, verified with the app's secret (Forgejo,
Gitea and GitHub HMAC signatures, or the secret as a token for CI). With a
Forgejo token stored, the panel adds the webhook to the repository itself.
The NixOS module routes /hooks/* past Authelia. Caddy matches the cleaned
path but forwards the original, so the panel refuses dot segments and only
accepts webhook deliveries from that route (tagged with X-Panel-Hook).
Domains: a route can point at a compose service's container port
("web:8080"). The panel picks a free 127.0.0.1 port and panelctl publishes
it through a generated .panel-ports.yaml override, so compose files need no
ports: section. Existing host:port upstreams keep working.
Logs stream live over server-sent events, with service and text filters.
A sampler keeps an hour of CPU and memory per container for the new
Monitoring tab. The Terminal tab opens `podman exec` in a container over a
WebSocket, using xterm.js bundled by the Nix package.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
211 lines
6.6 KiB
Nix
211 lines
6.6 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
|
|
let
|
|
cfg = config.services.reudy-panel;
|
|
forgejoServer = config.services.forgejo.settings.server;
|
|
routesFile = "${cfg.baseDir}/routes/routes.caddy";
|
|
in
|
|
{
|
|
options.services.reudy-panel = {
|
|
enable = lib.mkEnableOption "the panel for deploying Podman compose apps behind Caddy";
|
|
|
|
package = lib.mkOption {
|
|
type = lib.types.package;
|
|
default = pkgs.callPackage ./package.nix { };
|
|
defaultText = lib.literalExpression "pkgs.callPackage ./package.nix { }";
|
|
description = "The panel package to use.";
|
|
};
|
|
|
|
user = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "reudy";
|
|
description = ''
|
|
Existing user the panel runs as. Apps are deployed as rootless Podman
|
|
containers of this user, so it should have lingering enabled.
|
|
'';
|
|
};
|
|
|
|
group = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "panelroutes";
|
|
description = "Group shared by the panel and Caddy for the generated routes file.";
|
|
};
|
|
|
|
baseDir = lib.mkOption {
|
|
type = lib.types.path;
|
|
default = "/var/lib/containers";
|
|
description = "Where stacks, volumes, routes, state and backups are kept.";
|
|
};
|
|
|
|
listenAddress = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "127.0.0.1";
|
|
description = "Address the panel API listens on.";
|
|
};
|
|
|
|
port = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 9911;
|
|
description = "Port the panel API listens on.";
|
|
};
|
|
|
|
domain = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
example = "panel.example.com";
|
|
description = "Serve the panel on this domain through Caddy. Null disables the virtual host.";
|
|
};
|
|
|
|
autheliaAddress = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
example = "127.0.0.1:9091";
|
|
description = ''
|
|
Authelia instance that protects the panel's virtual host with
|
|
forward_auth. The panel has no login of its own, so leave this null
|
|
only if something else guards it.
|
|
'';
|
|
};
|
|
|
|
webhooks = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
description = ''
|
|
Let `/hooks/<app>` on the panel's domain through without Authelia, so
|
|
Forgejo, GitHub or CI can trigger deployments. Every delivery must be
|
|
signed with (or carry) the app's webhook secret, and the endpoint only
|
|
answers for apps with auto deploy switched on.
|
|
'';
|
|
};
|
|
|
|
forgejo.enable = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = config.services.forgejo.enable;
|
|
defaultText = lib.literalExpression "config.services.forgejo.enable";
|
|
description = ''
|
|
Point the panel at the local Forgejo instance (repo picker, private
|
|
clones, commit links). URLs are taken from services.forgejo.settings.
|
|
'';
|
|
};
|
|
|
|
environment = lib.mkOption {
|
|
type = lib.types.attrsOf lib.types.str;
|
|
default = { };
|
|
description = "Extra environment variables for the panel API service.";
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable {
|
|
environment.systemPackages = [ cfg.package ];
|
|
|
|
users.groups.${cfg.group} = { };
|
|
users.users.${cfg.user}.extraGroups = [ cfg.group ];
|
|
users.users.caddy.extraGroups = lib.mkIf config.services.caddy.enable [ cfg.group ];
|
|
|
|
systemd.tmpfiles.rules =
|
|
map (dir: "d ${cfg.baseDir}${dir} 0750 ${cfg.user} ${cfg.group} -") [
|
|
""
|
|
"/stacks"
|
|
"/volumes"
|
|
"/routes"
|
|
"/state"
|
|
"/state/apps"
|
|
"/backups"
|
|
]
|
|
++ [ "f ${routesFile} 0640 ${cfg.user} ${cfg.group} -" ];
|
|
|
|
systemd.services.panel-api = {
|
|
description = "Panel API";
|
|
after = [ "network.target" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
|
|
serviceConfig = {
|
|
Type = "simple";
|
|
User = cfg.user;
|
|
Group = cfg.group;
|
|
Restart = "always";
|
|
RestartSec = 3;
|
|
WorkingDirectory = cfg.baseDir;
|
|
ExecStart = lib.getExe cfg.package;
|
|
};
|
|
|
|
environment = {
|
|
PANEL_API_BIND = cfg.listenAddress;
|
|
PANEL_API_PORT = toString cfg.port;
|
|
PANEL_BASE_DIR = toString cfg.baseDir;
|
|
PANEL_USER = cfg.user;
|
|
PANEL_GROUP = cfg.group;
|
|
PANELCTL_PATH = lib.getExe' cfg.package "panelctl";
|
|
}
|
|
// lib.optionalAttrs (cfg.domain != null) {
|
|
# Used for the webhook URLs handed to Forgejo / GitHub.
|
|
PANEL_PUBLIC_URL = "https://${cfg.domain}";
|
|
}
|
|
// lib.optionalAttrs cfg.forgejo.enable {
|
|
# The API is reached on localhost; clones use the public URLs.
|
|
PANEL_FORGEJO_URL = lib.removeSuffix "/" forgejoServer.ROOT_URL;
|
|
PANEL_FORGEJO_API_URL = "http://${forgejoServer.HTTP_ADDR}:${toString forgejoServer.HTTP_PORT}";
|
|
PANEL_FORGEJO_SSH_URL = "ssh://${
|
|
forgejoServer.BUILTIN_SSH_SERVER_USER or config.services.forgejo.user
|
|
}@${forgejoServer.DOMAIN}:${toString forgejoServer.SSH_PORT}";
|
|
}
|
|
// cfg.environment;
|
|
};
|
|
|
|
services.caddy = lib.mkIf config.services.caddy.enable {
|
|
# App routes generated by panelctl.
|
|
extraConfig = ''
|
|
import ${routesFile}
|
|
'';
|
|
|
|
virtualHosts = lib.mkIf (cfg.domain != null) {
|
|
${cfg.domain}.extraConfig =
|
|
let
|
|
upstream = "${cfg.listenAddress}:${toString cfg.port}";
|
|
in
|
|
lib.optionalString cfg.webhooks ''
|
|
# Push webhooks authenticate with the app's secret, not a login.
|
|
# The header tells the panel the request skipped forward_auth, so it
|
|
# accepts nothing but a webhook delivery from here (Caddy matches the
|
|
# cleaned path but forwards the original one).
|
|
handle /hooks/* {
|
|
reverse_proxy ${upstream} {
|
|
header_up X-Panel-Hook 1
|
|
}
|
|
}
|
|
''
|
|
+ ''
|
|
handle {
|
|
''
|
|
+ lib.optionalString (cfg.autheliaAddress != null) ''
|
|
forward_auth ${cfg.autheliaAddress} {
|
|
uri /api/authz/forward-auth
|
|
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
|
}
|
|
''
|
|
+ ''
|
|
reverse_proxy ${upstream}
|
|
}
|
|
'';
|
|
};
|
|
};
|
|
|
|
# Reload Caddy whenever panelctl rewrites the routes file.
|
|
systemd.paths.caddy-routes-reload = lib.mkIf config.services.caddy.enable {
|
|
wantedBy = [ "multi-user.target" ];
|
|
pathConfig.PathChanged = routesFile;
|
|
};
|
|
|
|
systemd.services.caddy-routes-reload = lib.mkIf config.services.caddy.enable {
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = "${config.systemd.package}/bin/systemctl reload caddy.service";
|
|
};
|
|
};
|
|
};
|
|
}
|