release: publish gitea allowlist and harden bundle deploy
This commit is contained in:
+11
-3
@@ -182,9 +182,13 @@ apply_pool_to_env_prod() {
|
||||
local root="$1" prof="$2"
|
||||
local env_file="$root/$ENV_PROD"
|
||||
[[ -f "$env_file" ]] || return 0
|
||||
if [[ ! -f "$prof" ]]; then
|
||||
warn "未找到资源档位文件: $prof,保留 $ENV_PROD 中现有数据库连接池配置"
|
||||
return 0
|
||||
fi
|
||||
local pool overflow
|
||||
pool="$(grep -E '^NEXUS_DB_POOL_SIZE=' "$prof" | cut -d= -f2-)"
|
||||
overflow="$(grep -E '^NEXUS_DB_MAX_OVERFLOW=' "$prof" | cut -d= -f2-)"
|
||||
pool="$(grep -E '^NEXUS_DB_POOL_SIZE=' "$prof" | cut -d= -f2- || true)"
|
||||
overflow="$(grep -E '^NEXUS_DB_MAX_OVERFLOW=' "$prof" | cut -d= -f2- || true)"
|
||||
[[ -n "$pool" ]] && sed -i "s|^NEXUS_DB_POOL_SIZE=.*|NEXUS_DB_POOL_SIZE=${pool}|" "$env_file"
|
||||
[[ -n "$overflow" ]] && sed -i "s|^NEXUS_DB_MAX_OVERFLOW=.*|NEXUS_DB_MAX_OVERFLOW=${overflow}|" "$env_file"
|
||||
}
|
||||
@@ -1019,10 +1023,14 @@ verify_health() {
|
||||
}
|
||||
|
||||
ensure_repo() {
|
||||
if [[ ! -d "$NEXUS_ROOT/.git" ]]; then
|
||||
if [[ ! -d "$NEXUS_ROOT" ]]; then
|
||||
error "未找到 $NEXUS_ROOT,请先: bash $0 install"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$SKIP_GIT" != true && ! -d "$NEXUS_ROOT/.git" ]]; then
|
||||
error "未找到 $NEXUS_ROOT/.git,请先: bash $0 install;如为发布包/rsync 部署请使用 --skip-git"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$NEXUS_ROOT/$ENV_PROD" ]]; then
|
||||
error "缺少 $NEXUS_ROOT/$ENV_PROD"
|
||||
exit 1
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Host profile: 1 vCPU / 4 GiB
|
||||
NEXUS_MEM_LIMIT=1g
|
||||
NEXUS_CPUS=1
|
||||
NEXUS_DB_POOL_SIZE=15
|
||||
NEXUS_DB_MAX_OVERFLOW=10
|
||||
@@ -0,0 +1,5 @@
|
||||
# Host profile: 2 vCPU / 8 GiB — default production
|
||||
NEXUS_MEM_LIMIT=2g
|
||||
NEXUS_CPUS=1.5
|
||||
NEXUS_DB_POOL_SIZE=30
|
||||
NEXUS_DB_MAX_OVERFLOW=20
|
||||
@@ -0,0 +1,5 @@
|
||||
# Host profile: 4 vCPU / 16 GiB
|
||||
NEXUS_MEM_LIMIT=4g
|
||||
NEXUS_CPUS=3
|
||||
NEXUS_DB_POOL_SIZE=50
|
||||
NEXUS_DB_MAX_OVERFLOW=30
|
||||
@@ -0,0 +1,120 @@
|
||||
# Nexus → Gitea 白名单同步说明(2026-07-09)
|
||||
|
||||
## 目标
|
||||
|
||||
在 Nexus 里统一管理登录 IP 白名单,并把同一份“合并后的有效 IP/CIDR”同步到 Gitea 前置 Nginx,做到:
|
||||
|
||||
- Nexus 登录白名单继续作为主数据源;
|
||||
- Gitea 不改源码、不改数据库;
|
||||
- 新增 `/app-v2` 管理面板,可保存配置、预览 Nginx include、手动同步;
|
||||
- 可开启自动同步:Nexus 白名单变化后自动写入 Gitea 机器并 reload Nginx。
|
||||
|
||||
## 调用链
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[管理员在 Nexus 修改白名单] --> B[server/api/settings.py]
|
||||
B --> C[ip_allowlist_refresh.do_refresh]
|
||||
C --> D[settings.login_allowed_ips]
|
||||
D --> E[gitea_allowlist_service.render_nginx_allowlist]
|
||||
E --> F[SSH remote_write_bytes 写入 include 文件]
|
||||
F --> G[nginx -t && systemctl reload nginx]
|
||||
H[/app-v2 GiteaAllowlistPanel] --> I[GET/POST /api/settings/gitea-allowlist]
|
||||
I --> E
|
||||
```
|
||||
|
||||
## 新增后端接口
|
||||
|
||||
- `GET /api/settings/gitea-allowlist`
|
||||
返回 Gitea 同步配置、Nexus 当前有效白名单、Nginx 可用 IP/CIDR、被跳过的域名项、include 预览、最近同步状态。
|
||||
|
||||
- `POST /api/settings/gitea-allowlist`
|
||||
保存配置:
|
||||
- `enabled`
|
||||
- `auto_sync`
|
||||
- `server_id`
|
||||
- `remote_path`
|
||||
- `reload_command`
|
||||
|
||||
- `POST /api/settings/gitea-allowlist/sync`
|
||||
立即同步到 Gitea 服务器。
|
||||
|
||||
## 新增配置项
|
||||
|
||||
配置仍写入现有 `settings` 表,不新建表:
|
||||
|
||||
```text
|
||||
gitea_allowlist_enabled
|
||||
gitea_allowlist_auto_sync
|
||||
gitea_allowlist_server_id
|
||||
gitea_allowlist_remote_path
|
||||
gitea_allowlist_reload_command
|
||||
gitea_allowlist_last_sync_at
|
||||
gitea_allowlist_last_sync_ok
|
||||
gitea_allowlist_last_sync_error
|
||||
```
|
||||
|
||||
默认 include 路径:
|
||||
|
||||
```text
|
||||
/etc/nginx/gitea-allowlist.conf
|
||||
```
|
||||
|
||||
默认 reload 命令:
|
||||
|
||||
```bash
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
## Nginx 接入方式
|
||||
|
||||
在 Gitea 的 Nginx `server` 或 `location /` 中 include Nexus 生成的文件:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
include /etc/nginx/gitea-allowlist.conf;
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 只把 IP/CIDR 写成 Nginx `allow` 指令。
|
||||
- Nexus 白名单里如果有域名,会在 Gitea 同步中跳过并写入注释,因为 Nginx `allow` 不支持域名动态解析。
|
||||
- `enabled=false` 时生成的 include 不包含 `allow/deny`,不会阻断访问。
|
||||
- `enabled=true` 但当前没有任何可用于 Nginx 的 IP/CIDR 时,后端会拒绝同步,避免误写 `deny all` 把 Gitea 锁死。
|
||||
- 远端路径要求绝对路径,拒绝 `/`、`/etc`、`/etc/nginx` 和控制字符。
|
||||
|
||||
## 自动同步触发点
|
||||
|
||||
开启 `gitea_allowlist_auto_sync=true` 后:
|
||||
|
||||
1. `POST /api/settings/ip-allowlist` 保存 Nexus 白名单后同步;
|
||||
2. `POST /api/settings/ip-allowlist/manual` 增加手动 IP 后同步;
|
||||
3. `DELETE /api/settings/ip-allowlist/ip` 删除手动 IP 后同步;
|
||||
4. 后台订阅刷新 `server/background/ip_allowlist_refresh.py` 成功更新 `login_allowed_ips` 后同步。
|
||||
|
||||
自动同步是 best-effort:失败只记录 `gitea_allowlist_last_sync_error`,不会影响 Nexus 原本白名单保存。
|
||||
|
||||
## 前端页面
|
||||
|
||||
新增 `/app-v2` 设置页中的 `Gitea 白名单同步` 面板:
|
||||
|
||||
- 开关:启用 Gitea 白名单、自动同步;
|
||||
- 输入:Gitea 服务器 ID、include 远端路径、reload 命令;
|
||||
- 展示:Nexus 合并白名单数量、Nginx 可用 IP/CIDR 数量、跳过项数量;
|
||||
- 展示:Nginx include 示例和即将写入文件预览;
|
||||
- 操作:保存配置、立即同步。
|
||||
|
||||
## 回滚
|
||||
|
||||
如果需要回滚:
|
||||
|
||||
1. 在 Nexus 关闭 `Gitea 白名单启用`;
|
||||
2. 点击“立即同步”,生成不拦截的 include;
|
||||
3. 或从 Gitea Nginx 配置中移除 `include /etc/nginx/gitea-allowlist.conf;`;
|
||||
4. 执行 `nginx -t && systemctl reload nginx`。
|
||||
@@ -0,0 +1,132 @@
|
||||
# Nexus 发布验证与线上代码对比报告(2026-07-09)
|
||||
|
||||
## 1. 本次发布包
|
||||
|
||||
- 发布分支:`release/20260708-axs`
|
||||
- 发布包:`C:\Users\uzuma\Documents\Codex\2026-07-06\yuu\outputs\nexus-release-20260709-gitea-allowlist.tar.gz`
|
||||
- SHA256:`85f45626116f89dec4b3ba8cfed1b17b53cae365e6ea1d52ed71808019a8387c`
|
||||
- 线上中心机:`20.24.218.235:/opt/nexus`
|
||||
- 发布方式:`publish_release_bundle.py` 上传/预检,`apply-release-bundle.sh` 远端 rsync 覆盖并执行 `deploy/nexus-1panel.sh upgrade --skip-git`
|
||||
|
||||
## 2. 本次修复点
|
||||
|
||||
### 2.1 恢复 Docker 资源档位文件
|
||||
|
||||
线上升级默认使用 `NEXUS_PROFILE=2c8g`,会读取:
|
||||
|
||||
```text
|
||||
docker/profiles/2c8g.env
|
||||
```
|
||||
|
||||
发布前分支中的 `docker/profiles/` 为空,导致升级阶段报:
|
||||
|
||||
```text
|
||||
grep: /opt/nexus/docker/profiles/2c8g.env: No such file or directory
|
||||
```
|
||||
|
||||
已恢复:
|
||||
|
||||
```text
|
||||
docker/profiles/1c4g.env
|
||||
docker/profiles/2c8g.env
|
||||
docker/profiles/4c16g.env
|
||||
```
|
||||
|
||||
### 2.2 增加 profile 缺失兜底
|
||||
|
||||
`deploy/nexus-1panel.sh` 的 `apply_pool_to_env_prod()` 已增加文件存在性判断:
|
||||
|
||||
- profile 文件存在:按档位写入数据库连接池配置;
|
||||
- profile 文件缺失:不再中断发布,保留 `docker/.env.prod` 中现有连接池配置并打印警告。
|
||||
|
||||
### 2.3 发布包扫描规则
|
||||
|
||||
发布包生成与扫描脚本已排除/拦截本地测试虚拟环境:
|
||||
|
||||
```text
|
||||
.venv-codex-test
|
||||
.venv-*
|
||||
```
|
||||
|
||||
避免本地测试环境污染发布包。
|
||||
|
||||
### 2.4 Windows OpenSSH 远端命令 quoting
|
||||
|
||||
`publish_release_bundle.py` 已修复 Windows OpenSSH 执行远端 `bash -lc` 时的引号问题,避免 `&&`、空格、shell 元字符被错误解释。
|
||||
|
||||
## 3. 发布验证结果
|
||||
|
||||
发布后线上检查结果:
|
||||
|
||||
```text
|
||||
容器:nexus-prod-nexus-1 Up (healthy)
|
||||
网络:1panel-network,nexus-prod_nexus-internal
|
||||
/health -> ok
|
||||
/app/ -> 200
|
||||
/app-v2/ -> 200
|
||||
/api/settings/gitea-allowlist -> 401 Missing Authorization header
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `401 Missing Authorization header` 是正常结果,表示 Gitea 白名单接口已上线且受认证保护;
|
||||
- 容器已接入 `1panel-network`,可以访问 1Panel MySQL/Redis;
|
||||
- `/app/` 与 `/app-v2/` 均可访问。
|
||||
|
||||
## 4. 线上代码对比方式
|
||||
|
||||
采用 manifest 快速对比,而不是整目录下载:
|
||||
|
||||
1. 本地保留主分支、发布分支、working 副本;
|
||||
2. 线上只生成 `/opt/nexus` 文件 SHA256 manifest;
|
||||
3. 把 manifest 拉回本地;
|
||||
4. 本地对比 manifest。
|
||||
|
||||
这种方式比反复下载线上全量代码更快,也更容易排除运行时文件干扰。
|
||||
|
||||
## 5. 本次线上 vs 本地 working 对比
|
||||
|
||||
报告文件:
|
||||
|
||||
```text
|
||||
C:\Users\uzuma\Documents\Codex\2026-07-06\yuu\work\nexus_code_compare\reports\compare-online-after-final-vs-working-20260709.md
|
||||
```
|
||||
|
||||
摘要:
|
||||
|
||||
```text
|
||||
相同文件:2446
|
||||
右侧新增:19
|
||||
右侧缺少/左侧删除:2
|
||||
内容不同:0
|
||||
读取错误:0
|
||||
```
|
||||
|
||||
结论:
|
||||
|
||||
- 核心代码内容差异为 `0`;
|
||||
- 右侧新增 19 个主要是本地开发残留,例如 `.cursor/`、`.bak-`、测试结果文件;
|
||||
- 右侧缺少 2 个是线上运行时文件,例如 `docker/.host-profile`、`var/layer3-health/fail_count`;
|
||||
- 不影响本次 Gitea 白名单、app-v2、全局 API Key 隐藏、宝塔登录相关修复上线判断。
|
||||
|
||||
## 6. 本次测试
|
||||
|
||||
本地相关测试:
|
||||
|
||||
```text
|
||||
pytest tests/test_gitea_allowlist_service.py tests/test_btpanel_login_url.py -q
|
||||
24 passed
|
||||
```
|
||||
|
||||
## 7. 建议后续固定流程
|
||||
|
||||
以后每次发布按这个顺序:
|
||||
|
||||
1. 本地生成发布包;
|
||||
2. 扫描发布包,阻止 `.venv-*`、密钥、临时文件进入;
|
||||
3. 上传到中心机并执行 preflight;
|
||||
4. apply 发布包;
|
||||
5. 验证 `/health`、`/app/`、`/app-v2/`、关键 API;
|
||||
6. 拉线上 manifest;
|
||||
7. 与本地 working/release 分支 manifest 对比;
|
||||
8. 确认 `changed: 0` 后再认为发布完成。
|
||||
@@ -102,6 +102,57 @@ export interface IpAllowlistState {
|
||||
last_refresh?: string | null
|
||||
}
|
||||
|
||||
export interface GiteaAllowlistServerInfo {
|
||||
id: number
|
||||
name?: string | null
|
||||
domain?: string | null
|
||||
port?: number | null
|
||||
username?: string | null
|
||||
is_online?: boolean | null
|
||||
}
|
||||
|
||||
export interface GiteaAllowlistState {
|
||||
enabled: boolean
|
||||
auto_sync: boolean
|
||||
server_id?: number | null
|
||||
server?: GiteaAllowlistServerInfo | null
|
||||
remote_path: string
|
||||
reload_command: string
|
||||
effective_ips: string[]
|
||||
effective_count: number
|
||||
nginx_allowed_ips: string[]
|
||||
nginx_allowed_count: number
|
||||
skipped_entries: string[]
|
||||
skipped_count: number
|
||||
preview: string
|
||||
preview_error?: string | null
|
||||
include_snippet: string
|
||||
last_sync_at?: string | null
|
||||
last_sync_ok?: boolean | null
|
||||
last_sync_error?: string | null
|
||||
}
|
||||
|
||||
export interface GiteaAllowlistSavePayload {
|
||||
enabled?: boolean
|
||||
auto_sync?: boolean
|
||||
server_id?: number | null
|
||||
remote_path?: string
|
||||
reload_command?: string
|
||||
}
|
||||
|
||||
export interface GiteaAllowlistSyncResult {
|
||||
success: boolean
|
||||
message: string
|
||||
remote_path: string
|
||||
allowed_count: number
|
||||
skipped_count: number
|
||||
reloaded: boolean
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
content?: string
|
||||
state?: GiteaAllowlistState
|
||||
}
|
||||
|
||||
export interface SettingsOverviewData {
|
||||
settings: SettingEntry[]
|
||||
allowlist?: IpAllowlistState
|
||||
@@ -350,7 +401,8 @@ function fallbackSettingsOverviewData(warning: string): SettingsOverviewData {
|
||||
{ key: 'system_name', value: 'Nexus', updated_at: updatedAt },
|
||||
{ key: 'system_title', value: 'Nexus 运维安全控制台', updated_at: updatedAt },
|
||||
{ key: 'api_base_url', value: '跟随后端环境配置', updated_at: updatedAt },
|
||||
{ key: 'heartbeat_timeout', value: '120', updated_at: updatedAt },
|
||||
{ key: 'heartbeat_timeout', value: '120', updated_at: updatedAt },
|
||||
|
||||
{ key: 'theme', value: 'dark', updated_at: updatedAt },
|
||||
],
|
||||
allowlist: {
|
||||
@@ -385,6 +437,28 @@ export async function fetchSettingsOverviewData(): Promise<SettingsOverviewData>
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGiteaAllowlist(): Promise<GiteaAllowlistState> {
|
||||
return apiFetch<GiteaAllowlistState>('/settings/gitea-allowlist')
|
||||
}
|
||||
|
||||
export async function saveGiteaAllowlist(payload: GiteaAllowlistSavePayload): Promise<GiteaAllowlistState> {
|
||||
const body = {
|
||||
...payload,
|
||||
server_id: payload.server_id ?? undefined,
|
||||
}
|
||||
return apiFetch<GiteaAllowlistState>('/settings/gitea-allowlist', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function syncGiteaAllowlist(reloadNginx = true): Promise<GiteaAllowlistSyncResult> {
|
||||
return apiFetch<GiteaAllowlistSyncResult>('/settings/gitea-allowlist/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reload_nginx: reloadNginx }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchAuditLogData(): Promise<AuditLogData> {
|
||||
try {
|
||||
const [auditList, alertList] = await Promise.all([
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, CheckCircle2, RefreshCw, Save, ShieldCheck, Server, TerminalSquare } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { fetchGiteaAllowlist, saveGiteaAllowlist, syncGiteaAllowlist } from '../api/dashboardApi'
|
||||
import type { GiteaAllowlistState } from '../api/dashboardApi'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface GiteaAllowlistPanelProps {
|
||||
servers: ServerAsset[]
|
||||
}
|
||||
|
||||
function formatSyncStatus(state?: GiteaAllowlistState): { tone: 'green' | 'amber' | 'red' | 'slate'; label: string } {
|
||||
if (!state?.last_sync_at) return { tone: 'slate', label: '尚未同步' }
|
||||
if (state.last_sync_ok === true) return { tone: 'green', label: '最近同步成功' }
|
||||
if (state.last_sync_ok === false) return { tone: 'red', label: '最近同步失败' }
|
||||
return { tone: 'amber', label: '同步状态未知' }
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (!error) return ''
|
||||
if (error instanceof ApiError) return error.message
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export function GiteaAllowlistPanel({ servers }: GiteaAllowlistPanelProps): JSX.Element {
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['gitea-allowlist'],
|
||||
queryFn: fetchGiteaAllowlist,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [autoSync, setAutoSync] = useState(false)
|
||||
const [serverIdDraft, setServerIdDraft] = useState('')
|
||||
const [remotePath, setRemotePath] = useState('/etc/nginx/gitea-allowlist.conf')
|
||||
const [reloadCommand, setReloadCommand] = useState('nginx -t && systemctl reload nginx')
|
||||
const [reloadNginx, setReloadNginx] = useState(true)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
setEnabled(data.enabled)
|
||||
setAutoSync(data.auto_sync)
|
||||
setServerIdDraft(data.server_id ? String(data.server_id) : '')
|
||||
setRemotePath(data.remote_path || '/etc/nginx/gitea-allowlist.conf')
|
||||
setReloadCommand(data.reload_command || 'nginx -t && systemctl reload nginx')
|
||||
}, [data])
|
||||
|
||||
const serverOptions = useMemo(() => {
|
||||
return servers
|
||||
.filter((server) => typeof server.sourceId === 'number')
|
||||
.slice(0, 500)
|
||||
.map((server) => ({ id: server.sourceId!, label: `${server.name} / ${server.ip}` }))
|
||||
}, [servers])
|
||||
|
||||
const selectedServer = useMemo(() => {
|
||||
const id = Number(serverIdDraft)
|
||||
return serverOptions.find((item) => item.id === id)
|
||||
}, [serverIdDraft, serverOptions])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => saveGiteaAllowlist({
|
||||
enabled,
|
||||
auto_sync: autoSync,
|
||||
server_id: serverIdDraft.trim() ? Number(serverIdDraft) : 0,
|
||||
remote_path: remotePath,
|
||||
reload_command: reloadCommand,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setMessage('Gitea 白名单配置已保存。')
|
||||
await queryClient.invalidateQueries({ queryKey: ['gitea-allowlist'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['settings-overview-v2'] })
|
||||
},
|
||||
onError: (saveError) => setMessage('保存失败:' + formatError(saveError)),
|
||||
})
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => syncGiteaAllowlist(reloadNginx),
|
||||
onSuccess: async (result) => {
|
||||
setMessage(result.success ? result.message : `同步失败:${result.message}`)
|
||||
await queryClient.invalidateQueries({ queryKey: ['gitea-allowlist'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['settings-overview-v2'] })
|
||||
},
|
||||
onError: (syncError) => setMessage('同步失败:' + formatError(syncError)),
|
||||
})
|
||||
|
||||
const status = formatSyncStatus(data)
|
||||
const busy = saveMutation.isPending || syncMutation.isPending
|
||||
|
||||
return (
|
||||
<Panel className="overflow-hidden p-0">
|
||||
<div className="border-b border-white/10 p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<Badge tone="purple" dot className="px-4 py-2">Gitea 白名单同步</Badge>
|
||||
<h3 className="mt-4 text-2xl font-black tracking-tight text-white">Nexus IP 白名单 → Gitea Nginx</h3>
|
||||
<p className="mt-2 max-w-3xl text-sm font-semibold leading-6 text-slate-400">
|
||||
不改 Gitea 源码;Nexus 生成 nginx include 文件,Gitea 入口只放行 Nexus 当前合并后的 IP/CIDR 白名单。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={status.tone} dot>{status.label}</Badge>
|
||||
<Badge tone={data?.enabled ? 'green' : 'slate'} dot>{data?.enabled ? 'Gitea 拦截启用' : 'Gitea 拦截关闭'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <Notice tone="red" message={'读取 Gitea 白名单配置失败:' + formatError(error)} /> : null}
|
||||
{message ? <Notice tone={message.includes('失败') ? 'red' : 'green'} message={message} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-0 xl:grid-cols-[minmax(0,.95fr)_minmax(0,1.05fr)]">
|
||||
<div className="space-y-5 border-b border-white/10 p-6 xl:border-b-0 xl:border-r">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<ToggleCard
|
||||
title="启用 Gitea 白名单"
|
||||
desc="启用后 include 文件会写入 allow + deny all;无可用 IP 时后端会阻止写入。"
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
<ToggleCard
|
||||
title="Nexus 变更后自动同步"
|
||||
desc="保存 Nexus 白名单、增删手动 IP、订阅刷新后,自动写入 Gitea include 并 reload nginx。"
|
||||
checked={autoSync}
|
||||
onChange={setAutoSync}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-black uppercase tracking-[0.16em] text-slate-500">Gitea 所在服务器 ID</span>
|
||||
<input
|
||||
list="gitea-server-options"
|
||||
value={serverIdDraft}
|
||||
onChange={(event) => setServerIdDraft(event.target.value.replace(/[^0-9]/g, ''))}
|
||||
placeholder="例如 219"
|
||||
className="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 font-mono text-sm font-bold text-white outline-none transition focus:border-cyan-300/40"
|
||||
/>
|
||||
<datalist id="gitea-server-options">
|
||||
{serverOptions.map((server) => <option key={server.id} value={server.id}>{server.label}</option>)}
|
||||
</datalist>
|
||||
<span className="mt-2 block text-xs font-semibold text-slate-500">
|
||||
{selectedServer ? `已选择:${selectedServer.label}` : data?.server ? `当前后端记录:${data.server.name || data.server.id} / ${data.server.domain || '-'}` : '可输入 Nexus 服务器资产 ID。'}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-black uppercase tracking-[0.16em] text-slate-500">远端 include 文件路径</span>
|
||||
<input
|
||||
value={remotePath}
|
||||
onChange={(event) => setRemotePath(event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 font-mono text-sm font-bold text-white outline-none transition focus:border-cyan-300/40"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-black uppercase tracking-[0.16em] text-slate-500">Reload 命令</span>
|
||||
<input
|
||||
value={reloadCommand}
|
||||
onChange={(event) => setReloadCommand(event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 font-mono text-sm font-bold text-white outline-none transition focus:border-cyan-300/40"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<input type="checkbox" checked={reloadNginx} onChange={(event) => setReloadNginx(event.target.checked)} className="mt-1 h-4 w-4 accent-cyan-300" />
|
||||
<span>
|
||||
<span className="block text-sm font-black text-white">立即同步时执行 nginx reload</span>
|
||||
<span className="mt-1 block text-xs font-semibold leading-5 text-slate-500">如果你只想先写文件检查内容,可以取消勾选。</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || isLoading}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
className="focus-ring inline-flex items-center gap-2 rounded-2xl bg-cyan-400 px-5 py-3 text-sm font-black text-slate-950 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Save className="h-4 w-4" /> 保存配置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || isLoading}
|
||||
onClick={() => syncMutation.mutate()}
|
||||
className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-emerald-300/25 bg-emerald-400/10 px-5 py-3 text-sm font-black text-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', syncMutation.isPending && 'animate-spin')} /> 立即同步
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-6">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Metric icon={ShieldCheck} label="Nexus 合并白名单" value={String(data?.effective_count ?? 0)} tone="text-cyan-200" />
|
||||
<Metric icon={Server} label="Nginx 可写 IP/CIDR" value={String(data?.nginx_allowed_count ?? 0)} tone="text-emerald-200" />
|
||||
<Metric icon={AlertTriangle} label="跳过域名/非法项" value={String(data?.skipped_count ?? 0)} tone={(data?.skipped_count ?? 0) > 0 ? 'text-amber-200' : 'text-slate-300'} />
|
||||
</div>
|
||||
|
||||
{data?.preview_error ? <Notice tone="amber" message={data.preview_error} /> : null}
|
||||
{data?.last_sync_error ? <Notice tone="red" message={'最近同步错误:' + data.last_sync_error} /> : null}
|
||||
|
||||
<CodeBlock title="Nginx include 示例" value={data?.include_snippet || '加载中...'} />
|
||||
<CodeBlock title="即将写入的 include 文件预览" value={data?.preview || (isLoading ? '加载中...' : '当前配置无法生成预览')} tall />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Notice({ tone, message }: { tone: 'green' | 'amber' | 'red'; message: string }): JSX.Element {
|
||||
const klass = tone === 'green'
|
||||
? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
|
||||
: tone === 'red'
|
||||
? 'border-red-400/20 bg-red-400/10 text-red-100'
|
||||
: 'border-amber-400/20 bg-amber-400/10 text-amber-100'
|
||||
return <div className={'mt-4 rounded-2xl border px-4 py-3 text-sm font-semibold leading-6 ' + klass}>{message}</div>
|
||||
}
|
||||
|
||||
function ToggleCard({ title, desc, checked, onChange }: { title: string; desc: string; checked: boolean; onChange: (value: boolean) => void }): JSX.Element {
|
||||
return (
|
||||
<button type="button" onClick={() => onChange(!checked)} className={cn('rounded-3xl border p-4 text-left transition', checked ? 'border-cyan-300/30 bg-cyan-400/10' : 'border-white/10 bg-slate-950/40 hover:bg-white/[0.04]')}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-black text-white">{title}</span>
|
||||
{checked ? <CheckCircle2 className="h-5 w-5 text-cyan-200" /> : <span className="h-5 w-5 rounded-full border border-white/20" />}
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-semibold leading-5 text-slate-500">{desc}</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ icon: Icon, label, value, tone }: { icon: LucideIcon; label: string; value: string; tone: string }): JSX.Element {
|
||||
return (
|
||||
<div className="rounded-3xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<div className="flex items-center gap-2 text-xs font-black uppercase tracking-[0.14em] text-slate-500"><Icon className={'h-4 w-4 ' + tone} /> {label}</div>
|
||||
<div className="mt-3 text-3xl font-black text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ title, value, tall = false }: { title: string; value: string; tall?: boolean }): JSX.Element {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl border border-white/10 bg-slate-950/60">
|
||||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-3 text-xs font-black uppercase tracking-[0.14em] text-slate-500"><TerminalSquare className="h-4 w-4 text-cyan-200" /> {title}</div>
|
||||
<pre className={cn('overflow-auto p-4 text-xs font-semibold leading-5 text-slate-200', tall ? 'max-h-80' : 'max-h-52')}><code>{value}</code></pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'
|
||||
import { isSensitiveKey, redactSensitiveText } from '@/lib/redaction'
|
||||
import { useAppStore } from '@/stores/appStore'
|
||||
import { fetchSettingsOverviewData } from '../api/dashboardApi'
|
||||
import { GiteaAllowlistPanel } from './GiteaAllowlistPanel'
|
||||
import type { SettingsOverviewData } from '../api/dashboardApi'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
@@ -221,6 +222,8 @@ export function SettingsOverviewPage({ servers, total, mode }: SettingsOverviewP
|
||||
))}
|
||||
</section>
|
||||
|
||||
<GiteaAllowlistPanel servers={servers} />
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[.9fr_1.1fr]">
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
||||
@@ -352,22 +352,6 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- API Key -->
|
||||
<v-card elevation="0" border rounded="lg" class="mb-4">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2">mdi-key</v-icon>
|
||||
API Key
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-text-field :model-value="showApiKey ? apiKeyValue : '••••••••'" label="API Key" variant="outlined" density="compact" readonly :type="showApiKey ? 'text' : 'password'" style="max-width: 320px" />
|
||||
<v-btn variant="text" size="small" :icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'" @click="revealApiKey" :loading="revealingApiKey" />
|
||||
<v-btn variant="text" size="small" icon="mdi-content-copy" @click="copyApiKey" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- IP Allowlist -->
|
||||
<v-card elevation="0" border rounded="lg">
|
||||
<v-card-title class="d-flex align-center">
|
||||
@@ -616,10 +600,6 @@ const disableTotpPassword = ref('')
|
||||
const disableTotpCode = ref('')
|
||||
const disablingTotp = ref(false)
|
||||
|
||||
// ── API Key ──
|
||||
const showApiKey = ref(false)
|
||||
const apiKeyValue = ref('')
|
||||
const revealingApiKey = ref(false)
|
||||
const revealingTelegramToken = ref(false)
|
||||
|
||||
// ── IP Allowlist ──
|
||||
@@ -1077,23 +1057,6 @@ function copyTotpSecret() {
|
||||
}
|
||||
}
|
||||
|
||||
async function revealApiKey() {
|
||||
if (showApiKey.value) {
|
||||
showApiKey.value = false
|
||||
return
|
||||
}
|
||||
revealingApiKey.value = true
|
||||
try {
|
||||
const res = await http.post<{ value: string }>('/settings/api-key/reveal', {})
|
||||
apiKeyValue.value = res.value
|
||||
showApiKey.value = true
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '获取 API Key 失败', 'error')
|
||||
} finally {
|
||||
revealingApiKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTelegramTokenVisibility() {
|
||||
if (showToken.value) {
|
||||
showToken.value = false
|
||||
@@ -1122,15 +1085,6 @@ async function toggleTelegramTokenVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
function copyApiKey() {
|
||||
if (!showApiKey.value || !apiKeyValue.value) {
|
||||
snackbar('请先点击眼睛图标显示 API Key', 'warning')
|
||||
return
|
||||
}
|
||||
navigator.clipboard.writeText(apiKeyValue.value)
|
||||
snackbar('已复制到剪贴板')
|
||||
}
|
||||
|
||||
async function toggleAllowlist() {
|
||||
try {
|
||||
await http.post(`/settings/ip-allowlist/toggle?enabled=${ipAllowlistEnabled.value ? 'true' : 'false'}`)
|
||||
|
||||
@@ -14,6 +14,7 @@ EXCLUDE_NAMES = {
|
||||
".venv",
|
||||
".venv-py314",
|
||||
".venv-py312-codex",
|
||||
".venv-codex-test",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
@@ -42,9 +43,12 @@ EXCLUDE_EXACT = {
|
||||
|
||||
|
||||
def should_include(path: Path, rel: str) -> bool:
|
||||
parts = set(Path(rel).parts)
|
||||
rel_parts = Path(rel).parts
|
||||
parts = set(rel_parts)
|
||||
if parts & EXCLUDE_NAMES:
|
||||
return False
|
||||
if any(part.startswith(".venv-") for part in rel_parts):
|
||||
return False
|
||||
if rel in EXCLUDE_EXACT:
|
||||
return False
|
||||
if any(rel == exact or rel.startswith(exact + "/") for exact in EXCLUDE_EXACT):
|
||||
|
||||
@@ -57,7 +57,10 @@ def build_scp_base(args: argparse.Namespace) -> list[str]:
|
||||
|
||||
|
||||
def remote_bash(ssh_base: list[str], remote: str, script: str, *, dry_run: bool = False) -> None:
|
||||
run([*ssh_base, remote, "bash", "-lc", script], dry_run=dry_run)
|
||||
# OpenSSH concatenates remote command argv with spaces before sending it
|
||||
# to the remote shell. Quote the bash -lc payload so operators such as
|
||||
# && stay inside bash rather than being interpreted by the login shell.
|
||||
run([*ssh_base, remote, "bash", "-lc", q(script)], dry_run=dry_run)
|
||||
|
||||
|
||||
def q(value: str | Path) -> str:
|
||||
|
||||
@@ -13,6 +13,7 @@ BAD_PATH_PARTS = [
|
||||
".venv",
|
||||
".venv-py314",
|
||||
".venv-py312-codex",
|
||||
".venv-codex-test",
|
||||
"venv",
|
||||
".pytest_cache",
|
||||
"__pycache__",
|
||||
@@ -38,6 +39,8 @@ def bad_path(name: str) -> bool:
|
||||
wrapped = f"/{name}/"
|
||||
if any(f"/{part}/" in wrapped for part in BAD_PATH_PARTS):
|
||||
return True
|
||||
if any(part.startswith(".venv-") for part in name.split("/")):
|
||||
return True
|
||||
if any(name.endswith(suffix) for suffix in BAD_EXACT_SUFFIXES):
|
||||
return True
|
||||
if any(name.endswith(suffix) for suffix in BAD_FILE_SUFFIXES):
|
||||
|
||||
+318
-27
@@ -56,6 +56,10 @@ MUTABLE_KEYS: set[str] = {
|
||||
"login_allowlist_enabled", "login_subscription_url",
|
||||
"login_subscription_ips", "login_manual_ips", "login_allowed_ips",
|
||||
"login_subscription_last_refresh",
|
||||
"gitea_allowlist_enabled", "gitea_allowlist_auto_sync",
|
||||
"gitea_allowlist_server_id", "gitea_allowlist_remote_path",
|
||||
"gitea_allowlist_reload_command", "gitea_allowlist_last_sync_at",
|
||||
"gitea_allowlist_last_sync_ok", "gitea_allowlist_last_sync_error",
|
||||
"notify_alert_cpu", "notify_alert_mem", "notify_alert_disk",
|
||||
"notify_alert_offline",
|
||||
"notify_recovery", "notify_time_drift",
|
||||
@@ -321,6 +325,296 @@ async def bing_wallpaper():
|
||||
return {"url": None}
|
||||
|
||||
|
||||
class GiteaAllowlistSaveRequest(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
auto_sync: Optional[bool] = None
|
||||
server_id: Optional[int] = None
|
||||
remote_path: Optional[str] = None
|
||||
reload_command: Optional[str] = None
|
||||
|
||||
|
||||
class GiteaAllowlistSyncRequest(BaseModel):
|
||||
reload_nginx: bool = True
|
||||
|
||||
|
||||
_GITEA_SETTING_ATTRS = {
|
||||
"gitea_allowlist_enabled": "GITEA_ALLOWLIST_ENABLED",
|
||||
"gitea_allowlist_auto_sync": "GITEA_ALLOWLIST_AUTO_SYNC",
|
||||
"gitea_allowlist_server_id": "GITEA_ALLOWLIST_SERVER_ID",
|
||||
"gitea_allowlist_remote_path": "GITEA_ALLOWLIST_REMOTE_PATH",
|
||||
"gitea_allowlist_reload_command": "GITEA_ALLOWLIST_RELOAD_COMMAND",
|
||||
"gitea_allowlist_last_sync_at": "GITEA_ALLOWLIST_LAST_SYNC_AT",
|
||||
"gitea_allowlist_last_sync_ok": "GITEA_ALLOWLIST_LAST_SYNC_OK",
|
||||
"gitea_allowlist_last_sync_error": "GITEA_ALLOWLIST_LAST_SYNC_ERROR",
|
||||
}
|
||||
|
||||
|
||||
def _bool_setting(value: bool) -> str:
|
||||
return "true" if value else "false"
|
||||
|
||||
|
||||
def _truncate_setting_error(message: object) -> str:
|
||||
return str(message or "")[:1000]
|
||||
|
||||
|
||||
async def _gitea_settings_map(repo: SettingRepositoryImpl) -> dict[str, str]:
|
||||
"""Read Gitea allowlist settings, using config defaults when DB rows do not exist."""
|
||||
from server.config import settings as _settings
|
||||
|
||||
values: dict[str, str] = {
|
||||
key: str(getattr(_settings, attr, "") or "")
|
||||
for key, attr in _GITEA_SETTING_ATTRS.items()
|
||||
}
|
||||
for item in await repo.get_all():
|
||||
if item.key in _GITEA_SETTING_ATTRS:
|
||||
values[item.key] = str(item.value or "")
|
||||
return values
|
||||
|
||||
|
||||
def _effective_login_allowlist_entries() -> list[str]:
|
||||
"""Return Nexus effective allowlist entries in the same order used by login checks."""
|
||||
from server.config import settings as _settings
|
||||
from server.application.services.gitea_allowlist_service import split_csv
|
||||
|
||||
entries = split_csv(_settings.LOGIN_ALLOWED_IPS)
|
||||
if not entries:
|
||||
entries = split_csv(_settings.LOGIN_SUBSCRIPTION_IPS) + split_csv(_settings.LOGIN_MANUAL_IPS)
|
||||
return list(dict.fromkeys(entries))
|
||||
|
||||
|
||||
async def _persist_gitea_sync_status(
|
||||
repo: SettingRepositoryImpl,
|
||||
*,
|
||||
ok: bool,
|
||||
error: str = "",
|
||||
) -> dict[str, str]:
|
||||
from datetime import datetime, timezone
|
||||
from server.infrastructure.settings_broadcast import publish_setting_changes
|
||||
|
||||
changes = {
|
||||
"gitea_allowlist_last_sync_at": datetime.now(timezone.utc).isoformat(),
|
||||
"gitea_allowlist_last_sync_ok": _bool_setting(ok),
|
||||
"gitea_allowlist_last_sync_error": _truncate_setting_error(error),
|
||||
}
|
||||
for key, value in changes.items():
|
||||
await repo.set(key, value)
|
||||
await publish_setting_changes(changes)
|
||||
return changes
|
||||
|
||||
|
||||
async def _build_gitea_allowlist_state(db: AsyncSession) -> dict:
|
||||
from server.application.services.gitea_allowlist_service import (
|
||||
build_nginx_include_snippet,
|
||||
normalize_ip_entries,
|
||||
parse_config,
|
||||
render_nginx_allowlist,
|
||||
)
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
settings_map = await _gitea_settings_map(repo)
|
||||
try:
|
||||
config = parse_config(settings_map)
|
||||
config_error = None
|
||||
except ValueError as exc:
|
||||
# Do not make the settings page unusable because of an old bad path in DB.
|
||||
config = parse_config({})
|
||||
config_error = str(exc)
|
||||
|
||||
effective_entries = _effective_login_allowlist_entries()
|
||||
allowed_entries, skipped_entries = normalize_ip_entries(effective_entries)
|
||||
preview = ""
|
||||
preview_error = config_error
|
||||
try:
|
||||
preview = render_nginx_allowlist(effective_entries, enabled=config.enabled).content
|
||||
except ValueError as exc:
|
||||
preview_error = str(exc)
|
||||
|
||||
server_info = None
|
||||
if config.server_id:
|
||||
server = await ServerRepositoryImpl(db).get_by_id(config.server_id)
|
||||
if server:
|
||||
server_info = {
|
||||
"id": server.id,
|
||||
"name": server.name,
|
||||
"domain": server.domain,
|
||||
"port": server.port,
|
||||
"username": server.username,
|
||||
"is_online": bool(server.is_online),
|
||||
}
|
||||
|
||||
return {
|
||||
"enabled": config.enabled,
|
||||
"auto_sync": config.auto_sync,
|
||||
"server_id": config.server_id,
|
||||
"server": server_info,
|
||||
"remote_path": config.remote_path,
|
||||
"reload_command": config.reload_command,
|
||||
"effective_ips": effective_entries,
|
||||
"effective_count": len(effective_entries),
|
||||
"nginx_allowed_ips": allowed_entries,
|
||||
"nginx_allowed_count": len(allowed_entries),
|
||||
"skipped_entries": skipped_entries,
|
||||
"skipped_count": len(skipped_entries),
|
||||
"preview": preview,
|
||||
"preview_error": preview_error,
|
||||
"include_snippet": build_nginx_include_snippet(config.remote_path),
|
||||
"last_sync_at": config.last_sync_at,
|
||||
"last_sync_ok": config.last_sync_ok,
|
||||
"last_sync_error": config.last_sync_error,
|
||||
}
|
||||
|
||||
|
||||
async def _sync_gitea_allowlist_from_db(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
reload_nginx: bool = True,
|
||||
) -> dict:
|
||||
from server.application.services.gitea_allowlist_service import (
|
||||
parse_config,
|
||||
sync_gitea_allowlist_to_server,
|
||||
)
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
settings_map = await _gitea_settings_map(repo)
|
||||
config = parse_config(settings_map)
|
||||
if not config.server_id:
|
||||
raise HTTPException(status_code=400, detail="请先配置 Gitea 所在服务器 ID")
|
||||
server = await ServerRepositoryImpl(db).get_by_id(config.server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail=f"未找到 Gitea 服务器: {config.server_id}")
|
||||
|
||||
try:
|
||||
result = await sync_gitea_allowlist_to_server(
|
||||
server=server,
|
||||
config=config,
|
||||
effective_entries=_effective_login_allowlist_entries(),
|
||||
reload_nginx=reload_nginx,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await _persist_gitea_sync_status(repo, ok=False, error=str(exc))
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
await _persist_gitea_sync_status(repo, ok=result.ok, error="" if result.ok else result.message + ": " + result.stderr)
|
||||
return {
|
||||
"success": result.ok,
|
||||
"message": result.message,
|
||||
"remote_path": result.remote_path,
|
||||
"allowed_count": result.allowed_count,
|
||||
"skipped_count": result.skipped_count,
|
||||
"reloaded": result.reloaded,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"content": result.content,
|
||||
}
|
||||
|
||||
|
||||
async def _auto_sync_gitea_allowlist_if_enabled(db: AsyncSession) -> None:
|
||||
"""Best-effort Gitea sync after Nexus allowlist changes; never fail the original request."""
|
||||
from server.application.services.gitea_allowlist_service import parse_config
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
try:
|
||||
config = parse_config(await _gitea_settings_map(repo))
|
||||
if not config.auto_sync:
|
||||
return
|
||||
result = await _sync_gitea_allowlist_from_db(db, reload_nginx=True)
|
||||
if not result.get("success"):
|
||||
logger.warning("Gitea allowlist auto-sync finished with failure: %s", result.get("message"))
|
||||
except Exception as exc: # noqa: BLE001 - auto sync must not break Nexus allowlist saves
|
||||
try:
|
||||
await _persist_gitea_sync_status(repo, ok=False, error=str(exc))
|
||||
except Exception:
|
||||
logger.debug("Failed to persist Gitea auto-sync error", exc_info=True)
|
||||
logger.warning("Gitea allowlist auto-sync skipped/failed: %s", exc)
|
||||
|
||||
|
||||
@router.get("/gitea-allowlist", response_model=dict)
|
||||
async def get_gitea_allowlist(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return Gitea nginx allowlist sync configuration and preview."""
|
||||
return await _build_gitea_allowlist_state(db)
|
||||
|
||||
|
||||
@router.post("/gitea-allowlist", response_model=dict)
|
||||
async def save_gitea_allowlist(
|
||||
payload: GiteaAllowlistSaveRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Save Gitea allowlist sync configuration without immediately syncing."""
|
||||
from server.application.services.gitea_allowlist_service import normalize_remote_path
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.settings_broadcast import publish_setting_changes
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
changes: dict[str, str] = {}
|
||||
if payload.enabled is not None:
|
||||
changes["gitea_allowlist_enabled"] = _bool_setting(payload.enabled)
|
||||
if payload.auto_sync is not None:
|
||||
changes["gitea_allowlist_auto_sync"] = _bool_setting(payload.auto_sync)
|
||||
if payload.server_id is not None:
|
||||
if payload.server_id <= 0:
|
||||
changes["gitea_allowlist_server_id"] = ""
|
||||
else:
|
||||
server = await ServerRepositoryImpl(db).get_by_id(payload.server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail=f"未找到 Gitea 服务器: {payload.server_id}")
|
||||
changes["gitea_allowlist_server_id"] = str(payload.server_id)
|
||||
if payload.remote_path is not None:
|
||||
try:
|
||||
changes["gitea_allowlist_remote_path"] = normalize_remote_path(payload.remote_path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if payload.reload_command is not None:
|
||||
command = payload.reload_command.strip()
|
||||
if "\x00" in command or "\n" in command or "\r" in command:
|
||||
raise HTTPException(status_code=400, detail="reload 命令不能包含控制字符")
|
||||
if len(command) > 500:
|
||||
raise HTTPException(status_code=400, detail="reload 命令过长")
|
||||
changes["gitea_allowlist_reload_command"] = command
|
||||
|
||||
for key, value in changes.items():
|
||||
await repo.set(key, value)
|
||||
if changes:
|
||||
await publish_setting_changes(changes)
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="update_gitea_allowlist",
|
||||
target_type="setting",
|
||||
detail=f"更新 Gitea 白名单同步配置: {', '.join(sorted(changes.keys()))}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
return await _build_gitea_allowlist_state(db)
|
||||
|
||||
|
||||
@router.post("/gitea-allowlist/sync", response_model=dict)
|
||||
async def sync_gitea_allowlist(
|
||||
payload: GiteaAllowlistSyncRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Write the current Nexus allowlist to the configured Gitea nginx include file."""
|
||||
result = await _sync_gitea_allowlist_from_db(db, reload_nginx=payload.reload_nginx)
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="sync_gitea_allowlist",
|
||||
target_type="setting",
|
||||
detail=f"success={result.get('success')} allowed={result.get('allowed_count')} skipped={result.get('skipped_count')}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
result["state"] = await _build_gitea_allowlist_state(db)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=dict)
|
||||
async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
||||
"""Get a single setting by key (sensitive values masked)"""
|
||||
@@ -334,29 +628,6 @@ async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: A
|
||||
return _format_setting_response(key, value, None)
|
||||
|
||||
|
||||
@router.post("/api-key/reveal", response_model=dict)
|
||||
async def reveal_api_key(
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reveal global API_KEY for authenticated admin (JWT required)."""
|
||||
repo = SettingRepositoryImpl(db)
|
||||
value = await repo.get("api_key")
|
||||
if value is None:
|
||||
raise HTTPException(status_code=404, detail="API_KEY not found")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="reveal_api_key",
|
||||
target_type="setting",
|
||||
detail="查看全局 API Key",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
return {"key": "api_key", "value": value}
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=dict)
|
||||
async def set_setting(
|
||||
key: str,
|
||||
@@ -1546,6 +1817,8 @@ async def set_ip_allowlist(
|
||||
|
||||
if sub_url_now:
|
||||
refresh = await do_refresh()
|
||||
else:
|
||||
await _auto_sync_gitea_allowlist_if_enabled(db)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
from server.domain.models import AuditLog
|
||||
@@ -1612,8 +1885,17 @@ async def add_manual_ips(
|
||||
|
||||
from server.infrastructure.settings_broadcast import publish_setting_changes
|
||||
from server.background.ip_allowlist_refresh import do_refresh
|
||||
await publish_setting_changes({"login_manual_ips": val})
|
||||
await do_refresh()
|
||||
changes = {"login_manual_ips": val}
|
||||
if not (_settings.LOGIN_SUBSCRIPTION_URL or "").strip():
|
||||
await repo.set("login_allowed_ips", val)
|
||||
await repo.set("login_subscription_ips", "")
|
||||
changes["login_allowed_ips"] = val
|
||||
changes["login_subscription_ips"] = ""
|
||||
await publish_setting_changes(changes)
|
||||
if (_settings.LOGIN_SUBSCRIPTION_URL or "").strip():
|
||||
await do_refresh()
|
||||
else:
|
||||
await _auto_sync_gitea_allowlist_if_enabled(db)
|
||||
|
||||
# S-07: Audit log
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
@@ -1646,8 +1928,17 @@ async def remove_allowlist_ip(
|
||||
|
||||
from server.infrastructure.settings_broadcast import publish_setting_changes
|
||||
from server.background.ip_allowlist_refresh import do_refresh
|
||||
await publish_setting_changes({"login_manual_ips": val})
|
||||
await do_refresh()
|
||||
changes = {"login_manual_ips": val}
|
||||
if not (_settings.LOGIN_SUBSCRIPTION_URL or "").strip():
|
||||
await repo.set("login_allowed_ips", val)
|
||||
await repo.set("login_subscription_ips", "")
|
||||
changes["login_allowed_ips"] = val
|
||||
changes["login_subscription_ips"] = ""
|
||||
await publish_setting_changes(changes)
|
||||
if (_settings.LOGIN_SUBSCRIPTION_URL or "").strip():
|
||||
await do_refresh()
|
||||
else:
|
||||
await _auto_sync_gitea_allowlist_if_enabled(db)
|
||||
|
||||
# S-07: Audit log
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Gitea IP allowlist sync service.
|
||||
|
||||
Nexus owns the login IP allowlist. This service renders the same effective
|
||||
allowlist into an nginx include file in front of Gitea, so Gitea access and
|
||||
Nexus login access can be controlled from one place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import posixpath
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from server.domain.models import Server
|
||||
from server.infrastructure.ssh.asyncssh_pool import remote_write_bytes, ssh_pool
|
||||
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
||||
from server.utils.files_elevation import FilesElevation
|
||||
|
||||
DEFAULT_REMOTE_PATH = "/etc/nginx/gitea-allowlist.conf"
|
||||
DEFAULT_RELOAD_COMMAND = "nginx -t && systemctl reload nginx"
|
||||
GITEA_ALLOWLIST_SETTING_KEYS = {
|
||||
"gitea_allowlist_enabled",
|
||||
"gitea_allowlist_auto_sync",
|
||||
"gitea_allowlist_server_id",
|
||||
"gitea_allowlist_remote_path",
|
||||
"gitea_allowlist_reload_command",
|
||||
"gitea_allowlist_last_sync_at",
|
||||
"gitea_allowlist_last_sync_ok",
|
||||
"gitea_allowlist_last_sync_error",
|
||||
}
|
||||
|
||||
_TRUTHY = frozenset({"true", "1", "yes", "on"})
|
||||
_NGINX_PATH_FORBIDDEN_CHARS = frozenset("\x00\n\r\t ;{}\\\"'`$|&<>")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GiteaAllowlistConfig:
|
||||
enabled: bool = False
|
||||
auto_sync: bool = False
|
||||
server_id: int | None = None
|
||||
remote_path: str = DEFAULT_REMOTE_PATH
|
||||
reload_command: str = DEFAULT_RELOAD_COMMAND
|
||||
last_sync_at: str | None = None
|
||||
last_sync_ok: bool | None = None
|
||||
last_sync_error: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderedAllowlist:
|
||||
content: str
|
||||
allowed_entries: list[str] = field(default_factory=list)
|
||||
skipped_entries: list[str] = field(default_factory=list)
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GiteaAllowlistSyncResult:
|
||||
ok: bool
|
||||
message: str
|
||||
remote_path: str
|
||||
allowed_count: int
|
||||
skipped_count: int
|
||||
reloaded: bool = False
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
content: str = ""
|
||||
|
||||
|
||||
def truthy(value: object, default: bool = False) -> bool:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return str(value).strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def split_csv(value: str | None) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.replace("\n", ",").split(",") if item.strip()]
|
||||
|
||||
|
||||
def parse_config(settings_map: dict[str, str | None]) -> GiteaAllowlistConfig:
|
||||
raw_server_id = (settings_map.get("gitea_allowlist_server_id") or "").strip()
|
||||
server_id: int | None = None
|
||||
if raw_server_id:
|
||||
try:
|
||||
parsed = int(raw_server_id)
|
||||
server_id = parsed if parsed > 0 else None
|
||||
except ValueError:
|
||||
server_id = None
|
||||
return GiteaAllowlistConfig(
|
||||
enabled=truthy(settings_map.get("gitea_allowlist_enabled")),
|
||||
auto_sync=truthy(settings_map.get("gitea_allowlist_auto_sync")),
|
||||
server_id=server_id,
|
||||
remote_path=normalize_remote_path(settings_map.get("gitea_allowlist_remote_path") or DEFAULT_REMOTE_PATH),
|
||||
reload_command=(settings_map.get("gitea_allowlist_reload_command") or DEFAULT_RELOAD_COMMAND).strip() or DEFAULT_RELOAD_COMMAND,
|
||||
last_sync_at=(settings_map.get("gitea_allowlist_last_sync_at") or "").strip() or None,
|
||||
last_sync_ok=None if settings_map.get("gitea_allowlist_last_sync_ok") in (None, "") else truthy(settings_map.get("gitea_allowlist_last_sync_ok")),
|
||||
last_sync_error=(settings_map.get("gitea_allowlist_last_sync_error") or "").strip() or None,
|
||||
)
|
||||
|
||||
|
||||
def normalize_remote_path(value: str) -> str:
|
||||
path = (value or DEFAULT_REMOTE_PATH).strip()
|
||||
if not path.startswith("/"):
|
||||
raise ValueError("Gitea 白名单远程路径必须是绝对路径")
|
||||
if any(ch in path for ch in _NGINX_PATH_FORBIDDEN_CHARS):
|
||||
raise ValueError("Gitea 白名单远程路径不能包含空白、控制字符或 nginx/shell 元字符")
|
||||
normalized = posixpath.normpath(path)
|
||||
if normalized in ("/", "/etc", "/etc/nginx"):
|
||||
raise ValueError("Gitea 白名单远程路径过于宽泛")
|
||||
if not normalized.endswith(".conf"):
|
||||
raise ValueError("Gitea 白名单远程路径必须指向 .conf 文件")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_ip_entries(entries: Iterable[str]) -> tuple[list[str], list[str]]:
|
||||
"""Return (nginx_allow_entries, skipped_entries).
|
||||
|
||||
nginx allow/deny accepts IP and CIDR ranges. Nexus login allowlist accepts
|
||||
domain names too; domain entries are intentionally skipped for Gitea nginx
|
||||
includes because nginx `allow` does not resolve domains.
|
||||
"""
|
||||
allowed: list[str] = []
|
||||
skipped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in entries:
|
||||
entry = (raw or "").strip()
|
||||
if not entry or entry in seen:
|
||||
continue
|
||||
seen.add(entry)
|
||||
try:
|
||||
if "/" in entry:
|
||||
normalized = str(ipaddress.ip_network(entry, strict=False))
|
||||
else:
|
||||
normalized = str(ipaddress.ip_address(entry))
|
||||
except ValueError:
|
||||
skipped.append(entry)
|
||||
continue
|
||||
allowed.append(normalized)
|
||||
return allowed, skipped
|
||||
|
||||
|
||||
def render_nginx_allowlist(entries: Iterable[str], *, enabled: bool) -> RenderedAllowlist:
|
||||
allowed, skipped = normalize_ip_entries(entries)
|
||||
lines = [
|
||||
"# Managed by Nexus. Do not edit manually.",
|
||||
"# Include this file inside the Gitea nginx server/location block.",
|
||||
f"# Generated at: {datetime.now(timezone.utc).isoformat()}",
|
||||
"",
|
||||
]
|
||||
if not enabled:
|
||||
lines.extend([
|
||||
"# Gitea allowlist sync is disabled in Nexus.",
|
||||
"# No allow/deny directives are emitted, so nginx will not block by this include.",
|
||||
])
|
||||
else:
|
||||
if not allowed:
|
||||
raise ValueError("Gitea 白名单已启用,但 Nexus 当前没有可用于 nginx allow 的 IP/CIDR")
|
||||
for entry in allowed:
|
||||
lines.append(f"allow {entry};")
|
||||
lines.append("deny all;")
|
||||
if skipped:
|
||||
lines.extend(["", "# Skipped entries: nginx allow only supports IP/CIDR."])
|
||||
for entry in skipped:
|
||||
safe = entry.replace("\n", " ").replace("\r", " ")[:120]
|
||||
lines.append(f"# skipped: {safe}")
|
||||
lines.append("")
|
||||
return RenderedAllowlist("\n".join(lines), allowed, skipped, enabled)
|
||||
|
||||
|
||||
async def sync_gitea_allowlist_to_server(
|
||||
*,
|
||||
server: Server,
|
||||
config: GiteaAllowlistConfig,
|
||||
effective_entries: Iterable[str],
|
||||
reload_nginx: bool = True,
|
||||
) -> GiteaAllowlistSyncResult:
|
||||
rendered = render_nginx_allowlist(effective_entries, enabled=config.enabled)
|
||||
remote_path = normalize_remote_path(config.remote_path)
|
||||
conn = None
|
||||
try:
|
||||
conn = await ssh_pool.acquire(server)
|
||||
await remote_write_bytes(
|
||||
conn,
|
||||
remote_path,
|
||||
rendered.content.encode("utf-8"),
|
||||
elevation=FilesElevation.ALWAYS,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - return structured API error
|
||||
if conn is not None:
|
||||
await ssh_pool.close_connection(server.id)
|
||||
return GiteaAllowlistSyncResult(
|
||||
ok=False,
|
||||
message=f"写入 Gitea 白名单失败: {str(exc)[:300]}",
|
||||
remote_path=remote_path,
|
||||
allowed_count=len(rendered.allowed_entries),
|
||||
skipped_count=len(rendered.skipped_entries),
|
||||
content=rendered.content,
|
||||
stderr=str(exc)[:1000],
|
||||
)
|
||||
finally:
|
||||
if conn is not None:
|
||||
await ssh_pool.release(server.id)
|
||||
|
||||
if not reload_nginx:
|
||||
return GiteaAllowlistSyncResult(
|
||||
ok=True,
|
||||
message="Gitea 白名单文件已写入,未执行 reload",
|
||||
remote_path=remote_path,
|
||||
allowed_count=len(rendered.allowed_entries),
|
||||
skipped_count=len(rendered.skipped_entries),
|
||||
reloaded=False,
|
||||
content=rendered.content,
|
||||
)
|
||||
|
||||
command = (config.reload_command or DEFAULT_RELOAD_COMMAND).strip()
|
||||
if not command:
|
||||
return GiteaAllowlistSyncResult(
|
||||
ok=True,
|
||||
message="Gitea 白名单文件已写入,未配置 reload 命令",
|
||||
remote_path=remote_path,
|
||||
allowed_count=len(rendered.allowed_entries),
|
||||
skipped_count=len(rendered.skipped_entries),
|
||||
reloaded=False,
|
||||
content=rendered.content,
|
||||
)
|
||||
|
||||
result = await exec_ssh_command_with_fallback(server, command, timeout=60)
|
||||
ok = result.get("exit_code") == 0
|
||||
return GiteaAllowlistSyncResult(
|
||||
ok=ok,
|
||||
message="Gitea 白名单已同步并 reload 成功" if ok else "Gitea 白名单已写入,但 nginx reload 失败",
|
||||
remote_path=remote_path,
|
||||
allowed_count=len(rendered.allowed_entries),
|
||||
skipped_count=len(rendered.skipped_entries),
|
||||
reloaded=ok,
|
||||
stdout=str(result.get("stdout") or "")[:3000],
|
||||
stderr=str(result.get("stderr") or "")[:3000],
|
||||
content=rendered.content,
|
||||
)
|
||||
|
||||
|
||||
def build_nginx_include_snippet(remote_path: str = DEFAULT_REMOTE_PATH) -> str:
|
||||
nginx_path = normalize_remote_path(remote_path)
|
||||
return "location / {\n include " + nginx_path + ";\n proxy_pass http://127.0.0.1:3000;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n}"
|
||||
@@ -115,6 +115,7 @@ async def _do_refresh() -> RefreshResult:
|
||||
"Subscription IPs refreshed: %d → combined %d (+ %d manual)",
|
||||
len(new_sub_ips), len(combined), len(manual_ips),
|
||||
)
|
||||
await _maybe_sync_gitea_allowlist_after_refresh()
|
||||
return RefreshResult(
|
||||
ok=True,
|
||||
subscription_ips=tuple(new_sub_ips),
|
||||
@@ -135,3 +136,60 @@ def get_last_refresh_time() -> str:
|
||||
|
||||
def get_subscription_count() -> int:
|
||||
return _last_subscription_count
|
||||
|
||||
|
||||
async def _maybe_sync_gitea_allowlist_after_refresh() -> None:
|
||||
"""Best-effort sync of the refreshed Nexus IP allowlist to Gitea nginx."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from server.config import settings
|
||||
from server.application.services.gitea_allowlist_service import (
|
||||
parse_config,
|
||||
split_csv,
|
||||
sync_gitea_allowlist_to_server,
|
||||
)
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
|
||||
from server.infrastructure.settings_broadcast import publish_setting_changes
|
||||
|
||||
settings_map = {
|
||||
"gitea_allowlist_enabled": settings.GITEA_ALLOWLIST_ENABLED,
|
||||
"gitea_allowlist_auto_sync": settings.GITEA_ALLOWLIST_AUTO_SYNC,
|
||||
"gitea_allowlist_server_id": settings.GITEA_ALLOWLIST_SERVER_ID,
|
||||
"gitea_allowlist_remote_path": settings.GITEA_ALLOWLIST_REMOTE_PATH,
|
||||
"gitea_allowlist_reload_command": settings.GITEA_ALLOWLIST_RELOAD_COMMAND,
|
||||
"gitea_allowlist_last_sync_at": settings.GITEA_ALLOWLIST_LAST_SYNC_AT,
|
||||
"gitea_allowlist_last_sync_ok": settings.GITEA_ALLOWLIST_LAST_SYNC_OK,
|
||||
"gitea_allowlist_last_sync_error": settings.GITEA_ALLOWLIST_LAST_SYNC_ERROR,
|
||||
}
|
||||
try:
|
||||
config = parse_config(settings_map)
|
||||
if not config.auto_sync:
|
||||
return
|
||||
if not config.server_id:
|
||||
raise RuntimeError("Gitea allowlist auto-sync enabled but server_id is empty")
|
||||
effective_entries = split_csv(settings.LOGIN_ALLOWED_IPS)
|
||||
async with AsyncSessionLocal() as session:
|
||||
repo = SettingRepositoryImpl(session)
|
||||
server = await ServerRepositoryImpl(session).get_by_id(config.server_id)
|
||||
if not server:
|
||||
raise RuntimeError(f"Gitea server not found: {config.server_id}")
|
||||
result = await sync_gitea_allowlist_to_server(
|
||||
server=server,
|
||||
config=config,
|
||||
effective_entries=effective_entries,
|
||||
reload_nginx=True,
|
||||
)
|
||||
changes = {
|
||||
"gitea_allowlist_last_sync_at": datetime.now(timezone.utc).isoformat(),
|
||||
"gitea_allowlist_last_sync_ok": "true" if result.ok else "false",
|
||||
"gitea_allowlist_last_sync_error": "" if result.ok else (result.message + ": " + result.stderr)[:1000],
|
||||
}
|
||||
for key, value in changes.items():
|
||||
await repo.set(key, value)
|
||||
await publish_setting_changes(changes)
|
||||
if not result.ok:
|
||||
logger.warning("Gitea allowlist auto-sync after subscription refresh failed: %s", result.message)
|
||||
except Exception as exc: # noqa: BLE001 - background refresh must continue even if Gitea sync fails
|
||||
logger.warning("Gitea allowlist auto-sync after subscription refresh skipped/failed: %s", exc)
|
||||
|
||||
@@ -119,6 +119,16 @@ class Settings(BaseSettings):
|
||||
# Beijing timestamp of last successful subscription fetch (persisted, not per-process)
|
||||
LOGIN_SUBSCRIPTION_LAST_REFRESH: str = ""
|
||||
|
||||
# Gitea nginx allowlist sync — controlled from Nexus settings UI
|
||||
GITEA_ALLOWLIST_ENABLED: str = "false"
|
||||
GITEA_ALLOWLIST_AUTO_SYNC: str = "false"
|
||||
GITEA_ALLOWLIST_SERVER_ID: str = ""
|
||||
GITEA_ALLOWLIST_REMOTE_PATH: str = "/etc/nginx/gitea-allowlist.conf"
|
||||
GITEA_ALLOWLIST_RELOAD_COMMAND: str = "nginx -t && systemctl reload nginx"
|
||||
GITEA_ALLOWLIST_LAST_SYNC_AT: str = ""
|
||||
GITEA_ALLOWLIST_LAST_SYNC_OK: str = ""
|
||||
GITEA_ALLOWLIST_LAST_SYNC_ERROR: str = ""
|
||||
|
||||
# Notification toggles — each alert type can be independently enabled/disabled
|
||||
# Stored as "true"/"false" strings in MySQL settings table
|
||||
NOTIFY_ALERT_CPU: str = "true" # CPU 超阈值告警
|
||||
@@ -170,6 +180,14 @@ class Settings(BaseSettings):
|
||||
"login_manual_ips": "LOGIN_MANUAL_IPS",
|
||||
"login_allowed_ips": "LOGIN_ALLOWED_IPS",
|
||||
"login_subscription_last_refresh": "LOGIN_SUBSCRIPTION_LAST_REFRESH",
|
||||
"gitea_allowlist_enabled": "GITEA_ALLOWLIST_ENABLED",
|
||||
"gitea_allowlist_auto_sync": "GITEA_ALLOWLIST_AUTO_SYNC",
|
||||
"gitea_allowlist_server_id": "GITEA_ALLOWLIST_SERVER_ID",
|
||||
"gitea_allowlist_remote_path": "GITEA_ALLOWLIST_REMOTE_PATH",
|
||||
"gitea_allowlist_reload_command": "GITEA_ALLOWLIST_RELOAD_COMMAND",
|
||||
"gitea_allowlist_last_sync_at": "GITEA_ALLOWLIST_LAST_SYNC_AT",
|
||||
"gitea_allowlist_last_sync_ok": "GITEA_ALLOWLIST_LAST_SYNC_OK",
|
||||
"gitea_allowlist_last_sync_error": "GITEA_ALLOWLIST_LAST_SYNC_ERROR",
|
||||
"api_base_url": "API_BASE_URL",
|
||||
"jwt_access_token_expire_minutes": "JWT_ACCESS_TOKEN_EXPIRE_MINUTES",
|
||||
"jwt_refresh_token_expire_days": "JWT_REFRESH_TOKEN_EXPIRE_DAYS",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
|
||||
from server.application.services.gitea_allowlist_service import (
|
||||
build_nginx_include_snippet,
|
||||
normalize_ip_entries,
|
||||
normalize_remote_path,
|
||||
render_nginx_allowlist,
|
||||
)
|
||||
|
||||
|
||||
def test_render_enabled_allowlist_emits_allow_and_deny_all():
|
||||
rendered = render_nginx_allowlist(["1.2.3.4", "192.168.1.0/24"], enabled=True)
|
||||
|
||||
assert "allow 1.2.3.4;" in rendered.content
|
||||
assert "allow 192.168.1.0/24;" in rendered.content
|
||||
assert "deny all;" in rendered.content
|
||||
assert rendered.allowed_entries == ["1.2.3.4", "192.168.1.0/24"]
|
||||
assert rendered.skipped_entries == []
|
||||
|
||||
|
||||
def test_render_skips_domain_entries_because_nginx_allow_needs_ip_or_cidr():
|
||||
rendered = render_nginx_allowlist(["1.2.3.4", "office.example.com"], enabled=True)
|
||||
|
||||
assert "allow 1.2.3.4;" in rendered.content
|
||||
assert "office.example.com" in rendered.skipped_entries
|
||||
assert "# skipped: office.example.com" in rendered.content
|
||||
|
||||
|
||||
def test_render_disabled_does_not_emit_deny_all():
|
||||
rendered = render_nginx_allowlist([], enabled=False)
|
||||
|
||||
assert "deny all;" not in rendered.content
|
||||
assert "allow " not in rendered.content
|
||||
assert "sync is disabled" in rendered.content
|
||||
|
||||
|
||||
def test_enabled_with_no_nginx_compatible_ips_fails_closed_without_writing_deny_all():
|
||||
with pytest.raises(ValueError, match="没有可用于 nginx allow"):
|
||||
render_nginx_allowlist(["office.example.com"], enabled=True)
|
||||
|
||||
|
||||
def test_normalize_ip_entries_deduplicates_and_normalizes_networks():
|
||||
allowed, skipped = normalize_ip_entries(["1.2.3.4", "1.2.3.4", "192.168.1.1/24", "bad-host"])
|
||||
|
||||
assert allowed == ["1.2.3.4", "192.168.1.0/24"]
|
||||
assert skipped == ["bad-host"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["relative/path", "/", "/etc", "/etc/nginx", "/tmp/a\nb", "/tmp/gitea.txt", "/tmp/gitea;deny.conf", "/tmp/has space.conf"])
|
||||
def test_normalize_remote_path_rejects_dangerous_paths(path):
|
||||
with pytest.raises(ValueError):
|
||||
normalize_remote_path(path)
|
||||
|
||||
|
||||
def test_build_include_snippet_uses_normalized_absolute_path():
|
||||
snippet = build_nginx_include_snippet("/etc/nginx/conf.d/../gitea-allowlist.conf")
|
||||
|
||||
assert "include /etc/nginx/gitea-allowlist.conf;" in snippet
|
||||
assert "proxy_pass http://127.0.0.1:3000;" in snippet
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,11 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#070b16" />
|
||||
<title>Nexus App V2</title>
|
||||
<script type="module" crossorigin src="/app-v2/assets/index-8f2_PJZM.js"></script>
|
||||
<script type="module" crossorigin src="/app-v2/assets/index-Didaf4sV.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/app-v2/assets/rolldown-runtime-Bh1tDfsg.js">
|
||||
<link rel="modulepreload" crossorigin href="/app-v2/assets/query-B5fiFlYp.js">
|
||||
<link rel="modulepreload" crossorigin href="/app-v2/assets/react-DGbf-iXy.js">
|
||||
<link rel="stylesheet" crossorigin href="/app-v2/assets/index-saKc1OPG.css">
|
||||
<link rel="modulepreload" crossorigin href="/app-v2/assets/query-DI6R08KR.js">
|
||||
<link rel="modulepreload" crossorigin href="/app-v2/assets/react-ChC0s3-8.js">
|
||||
<link rel="stylesheet" crossorigin href="/app-v2/assets/index-BooX2Zbq.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user