Files
nPanel/nix/module.nix
T

243 lines
8.3 KiB
Nix
Raw Permalink 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;
# GPU driver utilities belong to the active NixOS generation. They cannot be
# selected from nixpkgs here because they must match the host's configured
# NVIDIA driver.
hostRuntimeBin = "/run/current-system/sw/bin";
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 ORIGINAL_VERSION ${lib.escapeShellArg (cfg.package.version or "unknown")}
update_control_value MANAGED_PORT ${lib.escapeShellArg (toString cfg.port)}
update_control_value MANAGED_ENTRANCE ${lib.escapeShellArg cfg.securityEntrance}
update_control_value MANAGED_VERSION ${lib.escapeShellArg (cfg.package.version or "unknown")}
# nPanel 2.0.0-2.0.1 ran the agent with UMask=0077. Application archives
# were therefore extracted with root-only database configuration files,
# even when the public archive declared mode 0644. Repair only the known
# non-secret database server configuration files; never relax .env files,
# credentials, private keys, or the rest of the mutable application tree.
for app_root in \
"$state_dir/1panel/apps" \
"$state_dir/1panel/resource/apps"
do
if [ -d "$app_root" ]; then
find "$app_root" -type f \
\( -path '*/mysql/*/conf/my.cnf' \
-o -path '*/mysql-cluster/*/conf/my.cnf' \
-o -path '*/mariadb/*/conf/my.cnf' \) \
-exec chmod 0644 {} +
fi
done
'';
commonServiceConfig = {
User = "root";
Group = "root";
SupplementaryGroups = [ "docker" ];
WorkingDirectory = "/var/lib/npanel";
Environment = [ "PATH=${hostRuntimeBin}:${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";
# The upstream agent is a privileged host-management daemon. Its
# application ecosystem relies on the conventional service umask so
# non-root processes inside containers can read public configuration.
UMask = "0022";
};
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";
};
};
};
}