Files
nPanel/nix/module.nix
T

221 lines
7.0 KiB
Nix
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{ self }:
{ config, lib, pkgs, ... }:
let
inherit (lib) mkEnableOption mkIf mkOption types;
cfg = config.services.nPanel;
packageForSystem = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
toolPath = lib.makeBinPath [
pkgs.bash
pkgs.coreutils
pkgs.curl
pkgs.docker
pkgs.docker-compose
pkgs.findutils
pkgs.gnugrep
pkgs.gnused
pkgs.gnutar
pkgs.gzip
pkgs.iproute2
pkgs.openssl
pkgs.procps
pkgs.systemd
pkgs.util-linux
pkgs.which
];
# This file is intentionally mutable. It holds nPanel bootstrap state below
# stateDir and never enters the Nix store.
initialise = pkgs.writeShellScript "npanel-initialise" ''
set -euo pipefail
state_dir=${lib.escapeShellArg cfg.stateDir}
control_file="$state_dir/npanel.conf"
password_file=${lib.escapeShellArg (if cfg.initialPasswordFile == null then "" else cfg.initialPasswordFile)}
install -d -m 0750 "$state_dir"
if [ ! -s "$control_file" ]; then
if [ -n "$password_file" ]; then
password="$(cat "$password_file")"
if [ -z "$password" ]; then
echo "nPanel: initialPasswordFile is empty" >&2
exit 1
fi
else
# `head` deliberately closes the random stream after 32 bytes. Do not
# let that expected SIGPIPE be promoted by the script-wide pipefail.
password="$(set +o pipefail; tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)"
if [ "''${#password}" -ne 32 ]; then
echo "nPanel: failed to generate an initial password" >&2
exit 1
fi
(umask 077; printf '%s\n' "$password" > "$state_dir/initial-password")
echo "nPanel created a one-time bootstrap password in $state_dir/initial-password" >&2
fi
umask 077
cat > "$control_file" <<EOF
BASE_DIR=/var/lib/npanel
ORIGINAL_PORT=${toString cfg.port}
ORIGINAL_VERSION=${cfg.package.version or "unknown"}
ORIGINAL_USERNAME=${cfg.initialUser}
ORIGINAL_PASSWORD=$password
ORIGINAL_ENTRANCE=${cfg.securityEntrance}
LANGUAGE=${cfg.language}
PANEL_EDITION=cn
EOF
fi
# These are declarative NixOS settings. They must be rewritten on every
# start so a panel-side database edit cannot survive the next activation.
update_control_value() {
key="$1"
value="$2"
tmp="$(mktemp "$state_dir/.npanel.conf.XXXXXX")"
if grep -q "^$key=" "$control_file"; then
sed "s|^$key=.*|$key=$value|" "$control_file" > "$tmp"
else
cat "$control_file" > "$tmp"
printf '%s=%s\n' "$key" "$value" >> "$tmp"
fi
chmod 0600 "$tmp"
mv "$tmp" "$control_file"
}
update_control_value ORIGINAL_PORT ${lib.escapeShellArg (toString cfg.port)}
update_control_value ORIGINAL_ENTRANCE ${lib.escapeShellArg cfg.securityEntrance}
update_control_value MANAGED_PORT ${lib.escapeShellArg (toString cfg.port)}
update_control_value MANAGED_ENTRANCE ${lib.escapeShellArg cfg.securityEntrance}
'';
commonServiceConfig = {
User = "root";
Group = "root";
SupplementaryGroups = [ "docker" ];
WorkingDirectory = "/var/lib/npanel";
Environment = [ "PATH=${toolPath}" ];
# The process sees fixed compatibility paths. Custom locations are bound
# into that view; default locations need no self-bind mount.
BindPaths =
lib.optionals (cfg.stateDir != "/var/lib/npanel") [
"${cfg.stateDir}:/var/lib/npanel"
]
++ lib.optionals (cfg.runtimeDir != "/run/npanel") [
"${cfg.runtimeDir}:/run/npanel"
];
Restart = "on-failure";
RestartSec = "5s";
UMask = "0077";
PrivateTmp = true;
ProtectSystem = "full";
ProtectHome = false;
PrivateDevices = false;
NoNewPrivileges = false;
};
in
{
options.services.nPanel = {
enable = mkEnableOption "nPanel 单机 Docker 运维面板";
package = mkOption {
type = types.package;
default = packageForSystem;
defaultText = lib.literalExpression "self.packages.\${pkgs.system}.default";
description = "nPanel core 软件包。agent 与前端从该软件包的 passthru 属性取得。";
};
stateDir = mkOption {
type = types.str;
default = "/var/lib/npanel";
description = "nPanel 数据库、Compose 项目、上传、备份和应用运行时数据的可变目录。";
};
runtimeDir = mkOption {
type = types.str;
default = "/run/npanel";
description = "core 与 agent 的本地 Unix socket 等易失运行时数据目录。";
};
port = mkOption {
type = types.port;
default = 9999;
description = "nPanel Web 服务监听端口。此值由 NixOS 持续接管,不能在面板中修改。";
};
securityEntrance = mkOption {
type = types.strMatching "[A-Za-z0-9]{5,116}";
example = "npanelAccess";
description = "nPanel 安全入口路径后缀(不含斜杠)。此值由 NixOS 持续接管,不能在面板中修改。";
};
initialUser = mkOption {
type = types.str;
default = "admin";
description = "首次初始化 nPanel 内部登录系统时使用的管理员名称。";
};
initialPasswordFile = mkOption {
type = types.nullOr types.str;
default = null;
example = "/run/secrets/npanel-initial-password";
description = "首次初始化密码文件。null 时生成一次性密码并以 0600 保存到 stateDir/initial-password。";
};
language = mkOption {
type = types.enum [ "zh" "en" ];
default = "zh";
description = "首次初始化时的界面语言。";
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = config.virtualisation.docker.enable;
message = "services.nPanel 需要 virtualisation.docker.enable = true;Docker 的声明式配置仍由用户维护。";
}
{
assertion = cfg.stateDir != cfg.runtimeDir;
message = "services.nPanel.stateDir 与 runtimeDir 必须是不同目录。";
}
{
assertion = cfg.securityEntrance != "";
message = "services.nPanel.securityEntrance 是必填项,且必须为 5 到 116 位字母或数字。";
}
];
systemd.tmpfiles.rules = [
"d ${cfg.stateDir} 0750 root root -"
"d ${cfg.runtimeDir} 0750 root root -"
];
systemd.services.npanel-agent = {
description = "nPanel privileged local agent";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" "docker.service" "systemd-tmpfiles-setup.service" ];
requires = [ "docker.service" ];
serviceConfig = commonServiceConfig // {
ExecStartPre = [ "${initialise}" ];
ExecStart = "${cfg.package.passthru.agent}/bin/npanel-agent";
};
};
systemd.services.npanel-core = {
description = "nPanel web core";
wantedBy = [ "multi-user.target" ];
after = [ "npanel-agent.service" ];
requires = [ "npanel-agent.service" ];
serviceConfig = commonServiceConfig // {
ExecStartPre = [ "${initialise}" ];
ExecStart = "${cfg.package}/bin/npanel-core";
};
};
};
}