fix: make panel access settings declarative

This commit is contained in:
2026-07-29 12:24:38 +08:00
parent d1c0c5f5ed
commit f60d3467f2
14 changed files with 102 additions and 199 deletions
+9 -4
View File
@@ -17,7 +17,10 @@ nPanel 不编辑、生成、扫描或接管用户的 Flake、`hardware-configura
imports = [ inputs.npanel.nixosModules.default ];
virtualisation.docker.enable = true;
services.nPanel.enable = true;
services.nPanel = {
enable = true;
securityEntrance = "npanelAccess";
};
}
```
@@ -28,6 +31,8 @@ nPanel 不编辑、生成、扫描或接管用户的 Flake、`hardware-configura
services.nPanel = {
stateDir = "/srv/npanel";
runtimeDir = "/run/npanel";
port = 9999;
securityEntrance = "npanelAccess";
initialPasswordFile = "/run/secrets/npanel-initial-password";
};
}
@@ -37,9 +42,9 @@ nPanel 不编辑、生成、扫描或接管用户的 Flake、`hardware-configura
权限写入 `stateDir/initial-password`。账户及密码属于 nPanel 自身的可变状态,
不写入 Nix 配置或 Nix store。
首次启动会额外生成 16 位随机安全入口并以 `0600` 权限写入
`stateDir/initial-entrance`。使用 `http://127.0.0.1:<port>/<该文件内容>` 首次访问;
登录后可在面板安全设置中修改或关闭该入口。
使用 `http://127.0.0.1:<port>/<securityEntrance>` 访问面板。`port` 与
`securityEntrance` 是 NixOS 声明配置,在每次 nPanel 启动时同步到内部数据库;面板
不会提供修改它们的入口。
## 状态与权限
+10
View File
@@ -18,6 +18,7 @@ import (
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/utils/common"
"github.com/1Panel-dev/1Panel/core/utils/ctl_conf"
"github.com/gin-gonic/gin"
)
@@ -91,6 +92,10 @@ func (b *BaseApi) UpdateSetting(c *gin.Context) {
return
}
if req.Key == "SecurityEntrance" {
if ctl_conf.LoadWithoutPanic("MANAGED_ENTRANCE") != "" {
helper.ErrorWithDetail(c, http.StatusForbidden, "ErrInvalidParams", errors.New("SecurityEntrance is managed by NixOS"))
return
}
if !checkEntrancePattern(req.Value) {
helper.ErrorWithDetail(c, http.StatusBadRequest, "ErrInvalidParams", buserr.WithName("ErrEntranceFormat", req.Value))
return
@@ -330,6 +335,11 @@ func (b *BaseApi) UpdateBindInfo(c *gin.Context) {
// @Router /core/settings/port/update [post]
// @x-panel-log {"bodyKeys":["serverPort"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"修改系统端口 => [serverPort]","formatEN":"update system port => [serverPort]"}
func (b *BaseApi) UpdatePort(c *gin.Context) {
if ctl_conf.LoadWithoutPanic("MANAGED_PORT") != "" {
helper.ErrorWithDetail(c, http.StatusForbidden, "ErrInvalidParams", errors.New("ServerPort is managed by NixOS"))
return
}
var req dto.PortUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
+19
View File
@@ -27,11 +27,30 @@ func Init() {
}
handleUserInfo(global.CONF.Base.ChangeUserInfo, settingRepo)
applyManagedSettings(settingRepo)
generateKey()
initDockerConf()
}
// applyManagedSettings makes values declared by the NixOS module authoritative
// over the panel's mutable settings database on every core startup.
func applyManagedSettings(settingRepo repo.ISettingRepo) {
if port := ctl_conf.LoadWithoutPanic("MANAGED_PORT"); port != "" {
if err := settingRepo.Update("ServerPort", port); err != nil {
global.LOG.Fatalf("apply managed panel port failed, err: %v", err)
}
global.CONF.Conn.Port = port
}
if entrance := ctl_conf.LoadWithoutPanic("MANAGED_ENTRANCE"); entrance != "" {
if err := settingRepo.Update("SecurityEntrance", entrance); err != nil {
global.LOG.Fatalf("apply managed security entrance failed, err: %v", err)
}
global.CONF.Conn.Entrance = entrance
}
}
func handleUserInfo(tags string, settingRepo repo.ISettingRepo) {
if len(tags) == 0 {
return
+9 -4
View File
@@ -18,11 +18,16 @@ nPanel 是独立维护的、源自社区版 1Panel 的 NixOS 单机 Docker 服
imports = [ inputs.npanel.nixosModules.default ];
virtualisation.docker.enable = true;
services.nPanel.enable = true;
services.nPanel = {
enable = true;
securityEntrance = "npanelAccess";
};
}
```
具体选项名和默认状态目录将在打包阶段确定。以上示例不意味着用户需要按任何特定方式重组其配置文件。
`services.nPanel.port` 与 `services.nPanel.securityEntrance` 是服务访问面的声明式
配置。它们在每次 core 启动时覆盖 nPanel 内部数据库,面板不提供修改入口。以上示例
不意味着用户需要按任何特定方式重组其配置文件。
## 所有权边界
@@ -49,7 +54,7 @@ Nix 配置不能替代日常运维。下列功能保留在 nPanel 范围内:
- Docker、Compose、容器、镜像、网络、卷、镜像仓库、模板与日志;
- 应用商店、已安装应用、网站、运行时、反向代理、数据库、SSL 与 AI;
- 仪表盘、主机监控、资源历史、进程查看与进程终止;
- 用于运维数据与故障处理的 root 级文件操作,并准确将不可变的 NixOS 路径标记为只读;
- 用于运维数据与故障处理的 root 级文件操作。Nix store 与 `/etc` 生成链接等路径由系统权限拒绝写入;在未知用户 Flake 位置的前提下,面板不能自动识别和保护任意用户配置文件;
- 本地与远程 SSH 终端、容器终端与数据库终端;
- systemd 单元状态、journal 查看,以及运行时的启动、停止、重启与重载操作;
- 备份、恢复、告警与 1Panel 领域内的计划任务。
@@ -195,4 +200,4 @@ NixOS 模块从本项目源码打包 core、agent 和前端,并提供:
- 独立 Flake、可配置状态目录、运行时目录和 systemd core/agent 服务已实现。
- 前端与后端已移除持久化系统策略、工具箱、脚本库、快照、多节点和商业入口的可达路由。
- 保留 Docker、Compose、应用、网站、数据库、备份、文件、终端、监控、进程和日志的运行时运维能力。
- 核心、agent、前端和 Flake 求值必须在发布前持续执行构建与边界审计;NixOS VM 冒烟测试是发布门槛。
- 核心、agent、前端和 Flake 求值必须在发布前持续执行构建与边界审计;NixOS VM 冒烟测试是发布门槛,本轮受限修正不执行 VM 测试。
-12
View File
@@ -129,18 +129,6 @@ const containerRouter = {
permission: 'container_view',
},
},
{
path: 'setting',
name: 'ContainerSetting',
component: () => import('@/views/container/setting/index.vue'),
hidden: true,
meta: {
activeMenu: '/containers',
parent: 'menu.container',
title: 'container.setting',
permission: 'container_view',
},
},
],
},
],
-12
View File
@@ -86,18 +86,6 @@ const settingRouter = {
adminOnly: true,
},
},
{
path: 'safe',
name: 'Safe',
component: () => import('@/views/setting/safe/index.vue'),
hidden: true,
meta: {
parent: 'menu.settings',
title: 'setting.safe',
activeMenu: '/settings',
adminOnly: true,
},
},
],
},
],
@@ -32,21 +32,6 @@
<template #main>
<div>
<MainDiv :heightDiff="mode === 'upgrade' ? 280 : 300">
<el-alert type="info" :closable="false" v-if="mode === 'installed' && !isIntl">
<template #title>
<span class="flx-align-center">
{{ $t('app.installHelper') }}
<el-link
class="ml-5"
icon="Position"
@click="jumpToPath(router, '/containers/setting')"
type="primary"
>
{{ $t('firewall.quickJump') }}
</el-link>
</span>
</template>
</el-alert>
<div class="update-prompt" v-if="data === null">
<span>{{ mode === 'upgrade' ? $t('app.updatePrompt') : $t('app.installPrompt') }}</span>
<div>
@@ -164,7 +149,7 @@ import { routerToFileWithPath, routerToNameWithQuery } from '@/utils/router';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { useOperateNodeContext } from '@/composables/useOperateNodeContext';
const { currentNode, isMaster, currentNodeAddr, isIntl } = useGlobalStore();
const { currentNode, isMaster, currentNodeAddr } = useGlobalStore();
useOperateNodeContext(currentNode);
const data = ref<any>();
@@ -172,35 +172,14 @@
</el-descriptions>
</template>
</CardWithHeader>
<CardWithHeader :header="$t('container.setting')" class="card-interval">
<template #body>
<el-descriptions :column="1" border>
<el-descriptions-item label-width="200" :label="$t('container.sockPath')">
{{ countItem.sockPath }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.mirrors')">
<div v-for="item in countItem.mirrors" :key="item" :value="item" :label="item">
<div class="mt-1">
<el-tag>{{ item }}</el-tag>
</div>
</div>
</el-descriptions-item>
</el-descriptions>
<el-button class="mt-2" type="primary" link @click="routerToName('ContainerSetting')">
<el-icon class="mr-1"><Position /></el-icon>
{{ $t('container.goSetting') }}
</el-button>
</template>
</CardWithHeader>
</div>
<TaskLog ref="taskLogRef" width="70%" @close="loadUsage" />
</div>
</template>
<script lang="ts" setup>
import { containerItemStats, containerPrune, loadContainerStatus, loadDaemonJson } from '@/api/modules/container';
import { containerItemStats, containerPrune, loadContainerStatus } from '@/api/modules/container';
import DockerStatus from '@/views/container/docker-status/index.vue';
import { getAgentSettingInfo } from '@/api/modules/setting';
import { computeSize2 } from '@/utils/size';
import { newUUID } from '@/utils/id';
import TaskLog from '@/components/log/task/index.vue';
@@ -239,9 +218,6 @@ const countItem = reactive({
volumeReclaimable: 0,
buildCacheUsage: 0,
buildCacheReclaimable: 0,
mirrors: [],
sockPath: '',
});
const search = () => {
@@ -250,7 +226,6 @@ const search = () => {
}
loadContainerCount();
loadUsage();
loadContainerSetting();
};
const loadContainerCount = async () => {
@@ -291,14 +266,6 @@ const loadUsage = async () => {
});
};
const loadContainerSetting = async () => {
const res = await loadDaemonJson();
countItem.mirrors = res.data.registryMirrors || [];
const settingRes = await getAgentSettingInfo();
countItem.sockPath = settingRes.data.dockerSockPath || 'unix:///var/run/docker.sock';
};
const onClean = async (type: string, withAll: boolean) => {
let msg = '';
switch (type) {
@@ -2,7 +2,6 @@
<div>
<el-card v-if="isExist && !isActive && !prop.isHide" class="mask-prompt">
<span>{{ $t('container.serviceUnavailable') }}</span>
<el-button type="primary" link class="bt" @click="goSetting">【 {{ $t('container.setting') }} 】</el-button>
<span>{{ $t('container.startIn') }}</span>
</el-card>
<NoSuchService v-if="!isExist" name="Docker" />
@@ -13,7 +12,6 @@
import { onMounted, ref } from 'vue';
import { loadDockerStatus } from '@/api/modules/container';
import NoSuchService from '@/components/layout-content/no-such-service.vue';
import { routerToName } from '@/utils/router';
const prop = defineProps({
isHide: Boolean,
@@ -41,10 +39,6 @@ const loadStatus = async () => {
});
};
const goSetting = async () => {
routerToName('ContainerSetting');
};
onMounted(() => {
loadStatus();
});
-4
View File
@@ -43,9 +43,5 @@ const buttons = [
label: i18n.global.t('container.composeTemplate', 2),
path: '/containers/template',
},
{
label: i18n.global.t('container.setting', 2),
path: '/containers/setting',
},
];
</script>
-9
View File
@@ -13,15 +13,6 @@
<template #title>
<span class="flx-align-center">
<span>{{ $t('home.entranceHelper') }}</span>
<el-link
style="font-size: 12px; margin-left: 5px"
icon="Position"
v-if="isAdmin"
@click="jumpToPath(router, '/settings/safe')"
type="primary"
>
{{ $t('firewall.quickJump') }}
</el-link>
</span>
</template>
</el-alert>
-4
View File
@@ -21,10 +21,6 @@ const buttons = computed<RouterButton[]>(() => {
label: i18n.global.t('setting.panel'),
path: '/settings/panel',
},
{
label: i18n.global.t('setting.safe'),
path: '/settings/safe',
},
]
: []),
...(globalStore.hasPermission('alert_view')
+1 -48
View File
@@ -107,16 +107,6 @@
<span class="input-help">{{ $t('setting.systemIPHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('setting.proxy')" prop="proxyShow">
<el-input disabled v-model="form.proxyShow">
<template #append>
<el-button @click="onChangeProxy" icon="Setting">
{{ $t('commons.button.set') }}
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item
v-if="!isEnterprise"
:label="$t('setting.developerMode')"
@@ -155,7 +145,6 @@
<PanelName ref="panelNameRef" @search="search()" />
<SystemIP ref="systemIPRef" @search="search()" />
<Proxy ref="proxyRef" @search="search()" />
<Timeout ref="timeoutRef" @search="search()" />
<HideMenu ref="hideMenuRef" @search="search()" />
</div>
@@ -171,9 +160,7 @@ import { MsgSuccess } from '@/utils/message';
import Timeout from '@/views/setting/panel/timeout/index.vue';
import PanelName from '@/views/setting/panel/name/index.vue';
import SystemIP from '@/views/setting/panel/systemip/index.vue';
import Proxy from '@/views/setting/panel/proxy/index.vue';
import HideMenu from '@/views/setting/panel/hidemenu/index.vue';
import { getXpackProxyDocker } from '@/extensions/xpack';
import { getXpackSetting, updateXpackSettingByKey } from '@/utils/xpack';
import { setPrimaryColor } from '@/utils/theme';
import { codeEditorThemeStorageKey } from '@/utils/code-editor-theme';
@@ -220,15 +207,6 @@ const form = reactive({
developerMode: '',
systemIP: '',
proxyShow: '',
proxyUrl: '',
proxyType: '',
proxyPort: '',
proxyUser: '',
proxyPasswd: '',
proxyPasswdKeep: '',
proxyDocker: '',
hideMenu: '',
});
@@ -236,7 +214,6 @@ const show = ref();
const panelNameRef = ref();
const systemIPRef = ref();
const proxyRef = ref();
const timeoutRef = ref();
const hideMenuRef = ref();
const watermarkRef = ref();
@@ -278,22 +255,11 @@ const search = async () => {
form.docSource = res.data.docSource || 'withByRegion';
form.edition = res.data.edition;
form.proxyUrl = res.data.proxyUrl;
form.proxyType = res.data.proxyType;
form.proxyPort = res.data.proxyPort;
form.proxyShow = form.proxyUrl ? form.proxyUrl + ':' + form.proxyPort : unset.value;
form.proxyUser = res.data.proxyUser;
form.proxyPasswd = res.data.proxyPasswd;
form.proxyPasswdKeep = res.data.proxyPasswdKeep;
form.developerMode = res.data.developerMode;
form.hideMenu = res.data.hideMenu;
if (isXpackOrEE.value) {
const [xpackRes, proxyDockerRes] = await Promise.all([
getXpackSetting(),
getXpackProxyDocker().catch(() => null),
]);
const xpackRes = await getXpackSetting();
if (xpackRes) {
form.theme = xpackRes.data.theme || themeConfig.value.theme || 'light';
form.themeColor = JSON.parse(xpackRes.data.themeColor || '{"light":"#005eeb","dark":"#F0BE96"}');
@@ -309,7 +275,6 @@ const search = async () => {
watermark.value = null;
}
}
form.proxyDocker = proxyDockerRes?.data?.proxyDocker || '';
} else {
themeConfig.value.theme = form.theme;
}
@@ -324,18 +289,6 @@ const onChangeTimeout = () => {
const onChangeSystemIP = () => {
systemIPRef.value.acceptParams({ systemIP: form.systemIP });
};
const onChangeProxy = () => {
proxyRef.value.acceptParams({
url: form.proxyUrl,
type: form.proxyType,
port: form.proxyPort,
user: form.proxyUser,
passwd: form.proxyPasswd,
passwdKeep: form.proxyPasswdKeep,
proxyDocker: form.proxyDocker,
});
};
const onChangeHideMenus = () => {
hideMenuRef.value.acceptParams({
hideMenu: form.hideMenu,
+52 -46
View File
@@ -34,62 +34,58 @@ let
password_file=${lib.escapeShellArg (if cfg.initialPasswordFile == null then "" else cfg.initialPasswordFile)}
install -d -m 0750 "$state_dir"
if [ -s "$control_file" ]; then
# Older nPanel generations created this file without the mandatory
# upstream security-entrance setting. Repair that state in place while
# keeping all user-owned panel data and credentials untouched.
if ! grep -q '^ORIGINAL_ENTRANCE=' "$control_file"; then
entrance="$(set +o pipefail; tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)"
if [ "''${#entrance}" -ne 16 ]; then
echo "nPanel: failed to generate an initial security entrance" >&2
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
printf '\nORIGINAL_ENTRANCE=%s\n' "$entrance" >> "$control_file"
chmod 0600 "$control_file"
(umask 077; printf '%s\n' "$entrance" > "$state_dir/initial-entrance")
echo "nPanel created a one-time security entrance in $state_dir/initial-entrance" >&2
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
exit 0
fi
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
entrance="$(set +o pipefail; tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)"
if [ "''${#entrance}" -ne 16 ]; then
echo "nPanel: failed to generate an initial security entrance" >&2
exit 1
fi
(umask 077; printf '%s\n' "$entrance" > "$state_dir/initial-entrance")
echo "nPanel created a one-time security entrance in $state_dir/initial-entrance" >&2
umask 077
cat > "$control_file" <<EOF
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=$entrance
ORIGINAL_ENTRANCE=${cfg.securityEntrance}
LANGUAGE=${cfg.language}
PANEL_EDITION=cn
EOF
chmod 0600 "$control_file"
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 = {
@@ -126,7 +122,7 @@ in
package = mkOption {
type = types.package;
default = packageForSystem;
defaultText = lib.literalExpression "inputs.npanel.packages.\${pkgs.system}.default";
defaultText = lib.literalExpression "self.packages.\${pkgs.system}.default";
description = "nPanel core 软件包。agent 与前端从该软件包的 passthru 属性取得。";
};
@@ -145,7 +141,13 @@ in
port = mkOption {
type = types.port;
default = 9999;
description = "首次初始化时写入 nPanel 数据库的监听端口;之后请在 nPanel 自身设置中调整。";
description = "nPanel Web 服务监听端口。此值由 NixOS 持续接管,不能在面板中修改。";
};
securityEntrance = mkOption {
type = types.strMatching "[A-Za-z0-9]{5,116}";
example = "npanelAccess";
description = "nPanel 安全入口路径后缀(不含斜杠)。此值由 NixOS 持续接管,不能在面板中修改。";
};
initialUser = mkOption {
@@ -178,6 +180,10 @@ in
assertion = cfg.stateDir != cfg.runtimeDir;
message = "services.nPanel.stateDir 与 runtimeDir 必须是不同目录。";
}
{
assertion = cfg.securityEntrance != "";
message = "services.nPanel.securityEntrance 是必填项,且必须为 5 到 116 位字母或数字。";
}
];
systemd.tmpfiles.rules = [