{ 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' &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" < "$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")} ''; 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"; }; }; }; }