mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-08 12:10:19 +08:00
fix: restore container logging compatibility and normalize legacy policies
This commit is contained in:
@@ -15,11 +15,6 @@ APP_PORT=8084
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:beta
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:0.7.0-rc.1
|
||||
|
||||
# Compose 应用容器的非 root 数字身份。
|
||||
# install.sh 会自动写入安装用户的 UID/GID。
|
||||
AETHER_CONTAINER_UID=65532
|
||||
AETHER_CONTAINER_GID=65532
|
||||
|
||||
# API Key 前缀(默认 sk)
|
||||
API_KEY_PREFIX=sk
|
||||
|
||||
|
||||
+1
-1
@@ -44,5 +44,5 @@ EXPOSE 8084
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
USER 65532:65532
|
||||
USER 0:0
|
||||
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
||||
|
||||
@@ -157,4 +157,5 @@ EXPOSE 8084
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
USER 0:0
|
||||
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
|
||||
|
||||
@@ -156,4 +156,5 @@ EXPOSE 8084
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
USER 0:0
|
||||
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
||||
|
||||
@@ -50,82 +50,10 @@ chmod 600 .env
|
||||
./generate_keys.sh
|
||||
# 编辑 .env 设置 ADMIN_PASSWORD
|
||||
|
||||
# 3. 首次部署 / 更新 (从以下部署形态任选其一)
|
||||
# Postgres + Redis (推荐)
|
||||
# 3. Docker 部署 / 更新(PostgreSQL + Redis)
|
||||
docker compose pull && docker compose up -d
|
||||
# Single Node:同样使用 PostgreSQL + Redis,无需挂载本地数据库文件
|
||||
docker compose -f docker-compose.single-node.yml pull && docker compose -f docker-compose.single-node.yml up -d
|
||||
```
|
||||
|
||||
应用镜像默认以固定非 root 身份 `65532:65532` 运行;Compose 移除全部 Linux capabilities、禁止提权、启用只读根文件系统,并提供带 `nosuid,nodev,noexec` 的 `/tmp`。如需使用其他身份,可在 `.env` 中设置非零的 `AETHER_CONTAINER_UID` / `AETHER_CONTAINER_GID`。数据库使用独立 PostgreSQL 容器和 named volume,不再需要调整应用数据库目录的权限。
|
||||
|
||||
|
||||
### 一键更新
|
||||
|
||||
Docker Compose 部署后,可在部署目录直接执行:
|
||||
|
||||
```bash
|
||||
./update.sh
|
||||
```
|
||||
|
||||
`update.sh` 会拉取最新 `app` 镜像并重建 `app` 容器,Docker named volumes、`./data` 和 `./logs` 不会被删除。Single Node 部署也可显式指定:
|
||||
|
||||
```bash
|
||||
./update.sh --mode single-node
|
||||
```
|
||||
|
||||
现在仅支持 PostgreSQL。标准和单节点 Docker Compose 均部署 PostgreSQL + Redis;原生 systemd / launchd 安装需要显式提供 PostgreSQL `DATABASE_URL`,例如 `DATABASE_URL=postgresql://user:password@host:5432/aether`。旧数据库不会自动迁移或清空。升级时保留原有 PostgreSQL 密码、`JWT_SECRET_KEY` 和 `ENCRYPTION_KEY`,不要重新生成整个 `.env`。
|
||||
|
||||
仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,并由 Docker 轮转日志,避免非 root 用户被宿主机日志目录权限拖垮启动。如果你确实需要文件日志,需要在 compose 里把 `AETHER_LOG_DESTINATION` 改成 `file|both`,额外挂载目录到 `/opt/aether/logs`,并让它归 `.env` 中配置的容器 UID/GID 所有;只读根文件系统不会阻止显式可写挂载。
|
||||
|
||||
管理后台右上角“版本信息”会检测新版本。Docker Compose 部署只提示版本,实际更新继续执行 `./update.sh`;systemd / launchd / 二进制部署才使用后台自更新,流程是下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/<version>`,再切换 `/opt/aether/current` 并退出进程,交给 systemd / launchd 拉起新版本。
|
||||
|
||||
正式 Release 还会发布由 GitHub Actions OIDC / Sigstore 签发的 SLSA build provenance。需要验证发布者身份时,下载目标 tarball 和 `AETHER_RELEASE_PROVENANCE.sigstore.json`,并把 `TAG` 设置为对应 Release tag:
|
||||
|
||||
```bash
|
||||
gh attestation verify "aether-${TAG}-linux-amd64.tar.gz" \
|
||||
--repo fawney19/Aether \
|
||||
--signer-workflow fawney19/Aether/.github/workflows/release.yml \
|
||||
--source-ref "refs/tags/${TAG}" \
|
||||
--bundle AETHER_RELEASE_PROVENANCE.sigstore.json
|
||||
```
|
||||
|
||||
`docker-compose.yml` 中的官方 PostgreSQL 和 Redis 镜像均固定到多架构 OCI index digest。升级这些依赖时应在发布变更中显式更新 digest,避免同名 tag 在无人审查的情况下改变部署内容。
|
||||
|
||||
正式发布到 GHCR 和 Docker Hub 的多架构 Aether 镜像也带有同一 GitHub Actions OIDC / Sigstore provenance;生产 `Dockerfile.app` 的 BusyBox 与 Distroless 基础镜像同样固定到多架构 OCI index digest。
|
||||
|
||||
源码或本地构建版本不会启用后台在线更新,请继续使用源码更新流程。Docker Compose 用户如果希望“容器重建后也保持镜像层面的新版本”,仍建议定期运行 `./update.sh` 拉取并重建 app 镜像。服务器访问 GitHub 需要代理时,可设置 `AETHER_UPDATE_PROXY_URL`,也兼容 `UPDATE_PROXY_URL`、`HTTPS_PROXY`、`ALL_PROXY`、`HTTP_PROXY` 以及 `NO_PROXY`。共享出口触发 GitHub API 限流时,可设置只读 `AETHER_UPDATE_GITHUB_TOKEN`,也兼容 `GITHUB_TOKEN` / `GH_TOKEN`。下载总超时默认 600 秒,连续无响应/无数据默认 30 秒,可通过 `AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS` 和 `AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS` 调整。
|
||||
|
||||
标准和 Single Node Docker Compose 均使用 Docker named volume 存放 PostgreSQL 数据。
|
||||
|
||||
如果是本地源码构建镜像的部署,继续使用:
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
如果要在本机联调“管理后台在线更新”本身,可启动仓库内置的 release-layout 测试环境:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.release-local.yml up -d --build
|
||||
```
|
||||
|
||||
这套环境会用当前源码构建一个本地测试镜像,但编译为 `release` 类型,并默认伪装成 `v0.7.0`,这样后台会按正式发布版逻辑开放“立即更新”。默认监听 `http://127.0.0.1:18085`,数据目录使用 `./data-release-local`;日志默认走 `docker logs`,不会影响你正在跑的源码构建容器。
|
||||
|
||||
如果这套容器在 `prepare-update` 时访问 GitHub 失败,而你本机是通过代理出网,请在 `.env` 里把 `AETHER_UPDATE_PROXY_URL` 写成宿主机地址,例如 `http://host.docker.internal:7890`;容器内的 `127.0.0.1` 指向容器自身,不是宿主机。
|
||||
|
||||
如果想重置这套联调环境(包括 `/opt/aether/current` 和已下载的历史版本),执行:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.release-local.yml down -v
|
||||
```
|
||||
|
||||
可选变量:
|
||||
|
||||
- `AETHER_RELEASE_LOCAL_VERSION`:本地联调镜像对外声明的当前版本,默认 `v0.7.0`
|
||||
- `AETHER_RELEASE_LOCAL_PORT`:本地联调端口,默认 `18085`
|
||||
- `LOCAL_RELEASE_APP_IMAGE`:本地联调镜像名,默认 `aether-app:release-local`
|
||||
|
||||
### 一键安装(PostgreSQL + Redis)
|
||||
|
||||
```bash
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
DO $migration$
|
||||
DECLARE
|
||||
policy_column record;
|
||||
BEGIN
|
||||
FOR policy_column IN
|
||||
SELECT *
|
||||
FROM (VALUES
|
||||
('api_keys', 'allowed_providers'),
|
||||
('api_keys', 'allowed_api_formats'),
|
||||
('api_keys', 'allowed_models'),
|
||||
('api_keys', 'ip_rules'),
|
||||
('users', 'allowed_providers'),
|
||||
('users', 'allowed_api_formats'),
|
||||
('users', 'allowed_models'),
|
||||
('user_groups', 'allowed_providers'),
|
||||
('user_groups', 'allowed_api_formats'),
|
||||
('user_groups', 'allowed_models'),
|
||||
('provider_api_keys', 'api_formats'),
|
||||
('provider_api_keys', 'allowed_models')
|
||||
) AS policy_columns(table_name, column_name)
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
$statement$
|
||||
UPDATE public.%1$I
|
||||
SET %2$I = NULL
|
||||
WHERE json_typeof(%2$I::json) = 'null'
|
||||
OR (
|
||||
json_typeof(%2$I::json) = 'string'
|
||||
AND (%2$I::json #>> '{}') ~* '^[[:space:]]*(null)?[[:space:]]*$'
|
||||
)
|
||||
$statement$,
|
||||
policy_column.table_name,
|
||||
policy_column.column_name
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$migration$;
|
||||
|
||||
UPDATE public.management_tokens
|
||||
SET allowed_ips = NULL
|
||||
WHERE json_typeof(allowed_ips::json) = 'null';
|
||||
|
||||
UPDATE public.management_tokens
|
||||
SET permissions = NULL
|
||||
WHERE json_typeof(permissions::json) = 'null';
|
||||
@@ -27,6 +27,8 @@ use crate::lifecycle::bootstrap::postgres::{
|
||||
EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION, EMPTY_DATABASE_SNAPSHOT_SQL,
|
||||
};
|
||||
|
||||
mod policy_nulls;
|
||||
|
||||
/// A clean PostgreSQL database is bootstrapped from the schema snapshot first;
|
||||
/// migrations after the privacy/security frontier are intentionally left
|
||||
/// pending so their data-preserving changes still execute. Exercise the same
|
||||
@@ -1572,6 +1574,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
|
||||
20260831030000,
|
||||
20260901000000,
|
||||
20260903000000,
|
||||
20260908000000,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
use aether_data_contracts::repository::{
|
||||
auth::AuthApiKeyLookupKey, management_tokens::ManagementTokenReadRepository,
|
||||
};
|
||||
use aether_data_postgres::{
|
||||
SqlxAuthApiKeySnapshotReadRepository, SqlxManagementTokenRepository, SqlxUserReadRepository,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{migrate::Migrate, query, query_scalar, Connection, PgConnection, PgPool};
|
||||
|
||||
use super::{prepare_database_for_startup, ManagedPostgresServer, POSTGRES_MIGRATOR};
|
||||
use crate::lifecycle::migrate::run_migrations;
|
||||
|
||||
const POLICY_NULL_MIGRATION_VERSION: i64 = 20260908000000;
|
||||
const POLICY_COLUMNS: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"api_keys",
|
||||
&[
|
||||
"allowed_providers",
|
||||
"allowed_api_formats",
|
||||
"allowed_models",
|
||||
"ip_rules",
|
||||
],
|
||||
),
|
||||
(
|
||||
"users",
|
||||
&["allowed_providers", "allowed_api_formats", "allowed_models"],
|
||||
),
|
||||
(
|
||||
"user_groups",
|
||||
&["allowed_providers", "allowed_api_formats", "allowed_models"],
|
||||
),
|
||||
("provider_api_keys", &["api_formats", "allowed_models"]),
|
||||
("management_tokens", &["allowed_ips", "permissions"]),
|
||||
];
|
||||
|
||||
#[tokio::test]
|
||||
async fn postgres_policy_null_migration_preserves_non_null_policies_and_is_idempotent() {
|
||||
let migration = POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.find(|migration| migration.version == POLICY_NULL_MIGRATION_VERSION)
|
||||
.expect("legacy policy null migration should be embedded");
|
||||
let legacy_values = [
|
||||
Value::Null,
|
||||
json!("null"),
|
||||
json!(" NULL "),
|
||||
json!("\tNuLl\r\n"),
|
||||
json!(""),
|
||||
json!(" \t\r\n"),
|
||||
];
|
||||
let preserved_values = [
|
||||
json!([]),
|
||||
json!(["provider-allowed"]),
|
||||
json!(["openai:chat", "claude:chat"]),
|
||||
json!(["127.0.0.1/32"]),
|
||||
json!(["null"]),
|
||||
json!("provider-allowed"),
|
||||
json!("[\"provider-allowed\"]"),
|
||||
json!("null-provider"),
|
||||
json!("nullnull"),
|
||||
json!([null]),
|
||||
json!([" "]),
|
||||
json!([1]),
|
||||
json!({}),
|
||||
json!({"policy": null}),
|
||||
json!(true),
|
||||
json!(42),
|
||||
];
|
||||
let cases = std::iter::once((None, None))
|
||||
.chain(legacy_values.into_iter().map(|value| (Some(value), None)))
|
||||
.chain(
|
||||
preserved_values
|
||||
.into_iter()
|
||||
.map(|value| (Some(value.clone()), Some(value))),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
for storage_type in ["json", "jsonb"] {
|
||||
let Some(server) = ManagedPostgresServer::try_start()
|
||||
.await
|
||||
.expect("postgres policy null test should start or skip")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let pool = PgPool::connect(server.database_url())
|
||||
.await
|
||||
.expect("policy fixture pool should connect");
|
||||
|
||||
for &(table_name, columns) in POLICY_COLUMNS {
|
||||
let definitions = columns
|
||||
.iter()
|
||||
.map(|column| format!("{column} {storage_type}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let modes = if matches!(table_name, "users" | "user_groups") {
|
||||
", allowed_providers_mode text DEFAULT 'deny_all', \
|
||||
allowed_api_formats_mode text DEFAULT 'specific', \
|
||||
allowed_models_mode text DEFAULT 'inherit'"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
query(&format!(
|
||||
"CREATE TABLE public.{table_name} \
|
||||
(id integer PRIMARY KEY, {definitions}, metadata {storage_type}{modes})"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("policy fixture table should be created");
|
||||
let placeholders = vec![format!("$2::{storage_type}"); columns.len()].join(", ");
|
||||
let insert_sql = format!(
|
||||
"INSERT INTO public.{table_name} (id, {}, metadata) \
|
||||
VALUES ($1, {placeholders}, 'null'::{storage_type})",
|
||||
columns.join(", ")
|
||||
);
|
||||
for (case_index, (input, _)) in cases.iter().enumerate() {
|
||||
query(&insert_sql)
|
||||
.bind(i32::try_from(case_index).expect("fixture index should fit i32"))
|
||||
.bind(input.clone())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("policy fixture should insert");
|
||||
}
|
||||
}
|
||||
|
||||
for attempt in 0..2 {
|
||||
sqlx::raw_sql(&migration.sql)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("legacy policy null migration should execute");
|
||||
|
||||
for &(table_name, columns) in POLICY_COLUMNS {
|
||||
let expected_values = cases
|
||||
.iter()
|
||||
.map(|(input, expected)| match (table_name, input) {
|
||||
("management_tokens", Some(Value::String(_))) => input.clone(),
|
||||
_ => expected.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let expected_sql_null_ids = expected_values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, value)| value.is_none())
|
||||
.map(|(index, _)| i32::try_from(index).expect("fixture index should fit i32"))
|
||||
.collect::<Vec<_>>();
|
||||
for column in columns {
|
||||
let actual_values = query_scalar::<_, Option<Value>>(&format!(
|
||||
"SELECT {column} FROM public.{table_name} ORDER BY id"
|
||||
))
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("migrated policy values should be readable");
|
||||
assert_eq!(
|
||||
actual_values, expected_values,
|
||||
"{storage_type}: {table_name}.{column}, attempt {attempt}"
|
||||
);
|
||||
let sql_null_ids = query_scalar::<_, i32>(&format!(
|
||||
"SELECT id FROM public.{table_name} WHERE {column} IS NULL ORDER BY id"
|
||||
))
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("actual SQL NULL rows should be readable");
|
||||
assert_eq!(
|
||||
sql_null_ids, expected_sql_null_ids,
|
||||
"{storage_type}: {table_name}.{column} must contain actual SQL NULL"
|
||||
);
|
||||
}
|
||||
let metadata_preserved: bool = query_scalar(&format!(
|
||||
"SELECT bool_and(metadata IS NOT NULL AND json_typeof(metadata::json) = 'null') \
|
||||
FROM public.{table_name}"
|
||||
))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("unrelated JSON null metadata should remain readable");
|
||||
assert!(
|
||||
metadata_preserved,
|
||||
"{table_name}.metadata must not be cleared"
|
||||
);
|
||||
if matches!(table_name, "users" | "user_groups") {
|
||||
let modes: Vec<(String, String, String)> = sqlx::query_as(&format!(
|
||||
"SELECT DISTINCT allowed_providers_mode, allowed_api_formats_mode, \
|
||||
allowed_models_mode FROM public.{table_name}"
|
||||
))
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("policy modes should be readable");
|
||||
assert_eq!(
|
||||
modes,
|
||||
vec![(
|
||||
"deny_all".to_string(),
|
||||
"specific".to_string(),
|
||||
"inherit".to_string(),
|
||||
)],
|
||||
"{table_name} policy modes must not change"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pool.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn postgres_policy_null_migration_repairs_legacy_upgrade_before_auth_reads() {
|
||||
let Some(server) = ManagedPostgresServer::try_start()
|
||||
.await
|
||||
.expect("postgres policy upgrade test should start or skip")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut connection = PgConnection::connect(server.database_url())
|
||||
.await
|
||||
.expect("legacy database connection should open");
|
||||
connection
|
||||
.ensure_migrations_table()
|
||||
.await
|
||||
.expect("legacy migration bookkeeping should be created");
|
||||
for migration in POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.version < POLICY_NULL_MIGRATION_VERSION)
|
||||
{
|
||||
connection
|
||||
.apply(migration)
|
||||
.await
|
||||
.expect("previous PostgreSQL migrations should apply");
|
||||
}
|
||||
drop(connection);
|
||||
|
||||
let pool = PgPool::connect(server.database_url())
|
||||
.await
|
||||
.expect("legacy database pool should connect");
|
||||
sqlx::raw_sql(
|
||||
r#"
|
||||
INSERT INTO public.users (id, username, email_verified)
|
||||
VALUES ('policy-key-owner', 'policy-key-owner', FALSE),
|
||||
('legacy-policy-user', 'legacy-policy-user', FALSE);
|
||||
|
||||
UPDATE public.users
|
||||
SET allowed_providers = 'null',
|
||||
allowed_api_formats = '"null"',
|
||||
allowed_models = '""',
|
||||
allowed_models_mode = 'deny_all'
|
||||
WHERE id = 'legacy-policy-user';
|
||||
|
||||
INSERT INTO public.api_keys (
|
||||
id, user_id, key_hash, allowed_providers, allowed_api_formats, allowed_models, ip_rules
|
||||
)
|
||||
VALUES (
|
||||
'legacy-policy-key', 'policy-key-owner', repeat('a', 64), 'null', '"null"', '""', 'null'
|
||||
);
|
||||
|
||||
INSERT INTO public.user_groups (
|
||||
id, name, normalized_name, allowed_providers, allowed_api_formats, allowed_models,
|
||||
allowed_providers_mode, allowed_api_formats_mode, allowed_models_mode
|
||||
)
|
||||
VALUES (
|
||||
'legacy-policy-group', 'Legacy policy', 'legacy-policy', 'null', '"null"', '""',
|
||||
'deny_all', 'specific', 'inherit'
|
||||
);
|
||||
|
||||
INSERT INTO public.management_tokens (
|
||||
id, user_id, token_hash, name, allowed_ips, permissions
|
||||
)
|
||||
VALUES (
|
||||
'legacy-policy-token', 'policy-key-owner', repeat('b', 64), 'Legacy token', 'null', 'null'
|
||||
), (
|
||||
'restricted-policy-token', 'policy-key-owner', repeat('c', 64), 'Restricted token',
|
||||
'["127.0.0.1"]', '["admin:users:read"]'
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("legacy policy fixtures should insert");
|
||||
|
||||
let auth_repository = SqlxAuthApiKeySnapshotReadRepository::new(pool.clone());
|
||||
let users_repository = SqlxUserReadRepository::new(pool.clone());
|
||||
let tokens_repository = SqlxManagementTokenRepository::new(pool.clone());
|
||||
let api_key_error = auth_repository
|
||||
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId("legacy-policy-key"))
|
||||
.await
|
||||
.expect_err("legacy API key JSON null should reproduce the upgrade failure");
|
||||
assert!(api_key_error
|
||||
.to_string()
|
||||
.contains("api_keys.allowed_providers contains JSON null"));
|
||||
assert!(users_repository
|
||||
.find_user_auth_by_id("legacy-policy-user")
|
||||
.await
|
||||
.expect_err("legacy user JSON null should fail strict policy decoding")
|
||||
.to_string()
|
||||
.contains("users.allowed_providers contains JSON null"));
|
||||
assert!(users_repository
|
||||
.find_user_group_by_id("legacy-policy-group")
|
||||
.await
|
||||
.expect_err("legacy group JSON null should fail strict policy decoding")
|
||||
.to_string()
|
||||
.contains("user_groups.allowed_providers contains JSON null"));
|
||||
let legacy_token = tokens_repository
|
||||
.get_management_token_with_user("legacy-policy-token")
|
||||
.await
|
||||
.expect("legacy management token should be readable")
|
||||
.expect("legacy management token should exist");
|
||||
assert_eq!(legacy_token.token.allowed_ips, Some(Value::Null));
|
||||
assert_eq!(legacy_token.token.permissions, Some(Value::Null));
|
||||
|
||||
let pending = prepare_database_for_startup(&pool)
|
||||
.await
|
||||
.expect("legacy database startup preparation should succeed");
|
||||
assert_eq!(
|
||||
pending.first().map(|migration| migration.version),
|
||||
Some(POLICY_NULL_MIGRATION_VERSION)
|
||||
);
|
||||
run_migrations(&pool)
|
||||
.await
|
||||
.expect("startup should normalize legacy policies");
|
||||
|
||||
let snapshot = auth_repository
|
||||
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId("legacy-policy-key"))
|
||||
.await
|
||||
.expect("upgraded API key should decode")
|
||||
.expect("upgraded API key should still exist");
|
||||
assert!(snapshot.api_key_allowed_providers.is_none());
|
||||
assert!(snapshot.api_key_allowed_api_formats.is_none());
|
||||
assert!(snapshot.api_key_allowed_models.is_none());
|
||||
assert!(snapshot.api_key_ip_rules.is_none());
|
||||
let exported_keys = auth_repository
|
||||
.list_export_api_keys_by_ids(&["legacy-policy-key".to_string()])
|
||||
.await
|
||||
.expect("upgraded API key listing should decode");
|
||||
assert_eq!(exported_keys.len(), 1);
|
||||
assert!(exported_keys[0].allowed_providers.is_none());
|
||||
|
||||
let user = users_repository
|
||||
.find_user_auth_by_id("legacy-policy-user")
|
||||
.await
|
||||
.expect("upgraded user should decode")
|
||||
.expect("upgraded user should still exist");
|
||||
assert!(user.allowed_providers.is_none());
|
||||
assert!(user.allowed_api_formats.is_none());
|
||||
assert!(user.allowed_models.is_none());
|
||||
assert_eq!(user.allowed_models_mode, "deny_all");
|
||||
let group = users_repository
|
||||
.find_user_group_by_id("legacy-policy-group")
|
||||
.await
|
||||
.expect("upgraded group should decode")
|
||||
.expect("upgraded group should still exist");
|
||||
assert!(group.allowed_providers.is_none());
|
||||
assert!(group.allowed_api_formats.is_none());
|
||||
assert!(group.allowed_models.is_none());
|
||||
assert_eq!(group.allowed_providers_mode, "deny_all");
|
||||
assert_eq!(group.allowed_api_formats_mode, "specific");
|
||||
assert_eq!(group.allowed_models_mode, "inherit");
|
||||
|
||||
let legacy_token = tokens_repository
|
||||
.get_management_token_with_user("legacy-policy-token")
|
||||
.await
|
||||
.expect("upgraded management token should decode")
|
||||
.expect("upgraded management token should still exist");
|
||||
assert!(legacy_token.token.allowed_ips.is_none());
|
||||
assert!(legacy_token.token.permissions.is_none());
|
||||
let restricted_token = tokens_repository
|
||||
.get_management_token_with_user("restricted-policy-token")
|
||||
.await
|
||||
.expect("restricted management token should decode")
|
||||
.expect("restricted management token should still exist");
|
||||
assert_eq!(
|
||||
restricted_token.token.allowed_ips,
|
||||
Some(json!(["127.0.0.1"]))
|
||||
);
|
||||
assert_eq!(
|
||||
restricted_token.token.permissions,
|
||||
Some(json!(["admin:users:read"]))
|
||||
);
|
||||
|
||||
run_migrations(&pool)
|
||||
.await
|
||||
.expect("restarting an upgraded database should be safe");
|
||||
assert!(prepare_database_for_startup(&pool)
|
||||
.await
|
||||
.expect("upgraded database should remain current")
|
||||
.is_empty());
|
||||
pool.close().await;
|
||||
}
|
||||
@@ -666,7 +666,8 @@ impl RollingFileSink {
|
||||
config: FileLoggingConfig,
|
||||
cleanup: fn(&str, &FileLoggingConfig) -> io::Result<usize>,
|
||||
) -> io::Result<(Self, Option<StartupCleanupWarning>)> {
|
||||
fs::create_dir_all(&config.dir)?;
|
||||
fs::create_dir_all(&config.dir)
|
||||
.map_err(|error| log_destination_error("create log directory", &config.dir, error))?;
|
||||
let startup_cleanup_warning =
|
||||
cleanup(service_name, &config)
|
||||
.err()
|
||||
@@ -727,13 +728,39 @@ fn open_bucketed_log_file(dir: &Path, service_name: &str, bucket: &str) -> io::R
|
||||
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
|
||||
}
|
||||
|
||||
let file = options.open(&path)?;
|
||||
validate_open_log_file(&file, &path)?;
|
||||
let file = options
|
||||
.open(&path)
|
||||
.map_err(|error| log_destination_error("open log file", &path, error))?;
|
||||
validate_open_log_file(&file, &path)
|
||||
.map_err(|error| log_destination_error("validate log file", &path, error))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn log_destination_error(operation: &str, path: &Path, error: io::Error) -> io::Error {
|
||||
let mut message = format!("failed to {operation} {}: {error}", path.display());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let process_uid = unsafe { libc::geteuid() };
|
||||
let process_gid = unsafe { libc::getegid() };
|
||||
message.push_str(&format!(" (process uid={process_uid}, gid={process_gid})"));
|
||||
}
|
||||
message.push_str(
|
||||
"; check ownership and write permissions for the log directory and existing log files",
|
||||
);
|
||||
io::Error::new(error.kind(), message)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn validate_open_log_file(file: &File, path: &Path) -> io::Result<()> {
|
||||
validate_open_log_file_for_user(file, path, unsafe { libc::geteuid() })
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn validate_open_log_file_for_user(
|
||||
file: &File,
|
||||
path: &Path,
|
||||
process_uid: libc::uid_t,
|
||||
) -> io::Result<()> {
|
||||
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
|
||||
|
||||
let metadata = file.metadata()?;
|
||||
@@ -743,12 +770,14 @@ fn validate_open_log_file(file: &File, path: &Path) -> io::Result<()> {
|
||||
format!("log destination is not a regular file: {}", path.display()),
|
||||
));
|
||||
}
|
||||
if metadata.uid() != unsafe { libc::geteuid() } {
|
||||
if process_uid != 0 && metadata.uid() != process_uid {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"log destination is owned by another user: {}",
|
||||
path.display()
|
||||
"log destination is owned by another user: {} (owner uid={}, expected uid={process_uid}); \
|
||||
chmod does not change file ownership",
|
||||
path.display(),
|
||||
metadata.uid()
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -1081,6 +1110,121 @@ mod tests {
|
||||
fs::remove_dir_all(&warning.log_dir).expect("temp dir should be removable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_file_sink_reports_directory_creation_failure_with_context() {
|
||||
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&dir).expect("temp dir should exist");
|
||||
let blocker = dir.join("not-a-directory");
|
||||
fs::write(&blocker, b"unchanged").expect("blocking file should exist");
|
||||
let log_dir = blocker.join("logs");
|
||||
let config = FileLoggingConfig::new(&log_dir, LogRotation::Daily, 7, 30);
|
||||
|
||||
let error = RollingFileSink::new("runtime-test", config)
|
||||
.expect_err("a regular file must not be treated as a directory");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("failed to create log directory"));
|
||||
assert!(message.contains(&log_dir.display().to_string()));
|
||||
assert!(message.contains("ownership and write permissions"));
|
||||
assert_eq!(fs::read(&blocker).expect("blocking file"), b"unchanged");
|
||||
|
||||
fs::remove_dir_all(&dir).expect("temp dir should be removable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_log_file_reports_open_failure_with_context() {
|
||||
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
|
||||
let path = bucketed_log_path(&dir, "runtime-test", "missing");
|
||||
|
||||
let error = open_bucketed_log_file(&dir, "runtime-test", "missing")
|
||||
.expect_err("a missing parent directory must prevent file creation");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("failed to open log file"));
|
||||
assert!(message.contains(&path.display().to_string()));
|
||||
assert!(message.contains("ownership and write permissions"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
assert!(message.contains(&format!("process uid={}", unsafe { libc::geteuid() })));
|
||||
assert!(message.contains(&format!("gid={}", unsafe { libc::getegid() })));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rolling_log_file_rejects_another_owner_even_with_world_writable_permissions() {
|
||||
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&dir).expect("temp dir should exist");
|
||||
let path = bucketed_log_path(&dir, "runtime-test", "another-owner");
|
||||
fs::write(&path, b"unchanged").expect("log file should exist");
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o777))
|
||||
.expect("log permissions should be writable by all users");
|
||||
let file = fs::File::open(&path).expect("log should open");
|
||||
let owner_uid = file.metadata().expect("log metadata").uid();
|
||||
let process_uid = owner_uid.wrapping_add(1);
|
||||
|
||||
let error = super::validate_open_log_file_for_user(&file, &path, process_uid)
|
||||
.expect_err("chmod must not bypass the ownership check");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("log destination is owned by another user"));
|
||||
assert!(message.contains(&format!("owner uid={owner_uid}")));
|
||||
assert!(message.contains(&format!("expected uid={process_uid}")));
|
||||
assert!(message.contains("chmod does not change file ownership"));
|
||||
assert_eq!(fs::read(&path).expect("log contents"), b"unchanged");
|
||||
assert_eq!(
|
||||
file.metadata().expect("log metadata").permissions().mode() & 0o777,
|
||||
0o777
|
||||
);
|
||||
drop(file);
|
||||
|
||||
let reopened = open_bucketed_log_file(&dir, "runtime-test", "another-owner")
|
||||
.expect("the actual owner should be able to reopen the log");
|
||||
assert_eq!(
|
||||
reopened
|
||||
.metadata()
|
||||
.expect("log metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(fs::read(&path).expect("log contents"), b"unchanged");
|
||||
drop(reopened);
|
||||
|
||||
fs::remove_dir_all(&dir).expect("temp dir should be removable");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn root_log_validation_accepts_existing_owners_but_rejects_hardlinks() {
|
||||
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&dir).expect("temp dir should exist");
|
||||
let path = bucketed_log_path(&dir, "runtime-test", "existing-owner");
|
||||
fs::write(&path, b"unchanged").expect("log file should exist");
|
||||
let file = fs::File::open(&path).expect("log should open");
|
||||
let owner_uid = file.metadata().expect("log metadata").uid();
|
||||
|
||||
super::validate_open_log_file_for_user(&file, &path, 0)
|
||||
.expect("root may use a regular log file without changing its owner");
|
||||
let metadata = file.metadata().expect("log metadata");
|
||||
assert_eq!(metadata.uid(), owner_uid);
|
||||
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(fs::read(&path).expect("log contents"), b"unchanged");
|
||||
|
||||
fs::hard_link(&path, dir.join("other-link.log")).expect("hardlink should exist");
|
||||
let error = super::validate_open_log_file_for_user(&file, &path, 0)
|
||||
.expect_err("root must still reject a log file with multiple hard links");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
assert!(error.to_string().contains("multiple hard links"));
|
||||
drop(file);
|
||||
|
||||
fs::remove_dir_all(&dir).expect("temp dir should be removable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pretty_formatter_omits_service_identity_fields() {
|
||||
let writer = SharedBuffer::default();
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use aether_runtime::{
|
||||
init_reloadable_service_tracing, init_service_runtime, FileLoggingConfig, LogDestination,
|
||||
LogFormat, LogRotation, ServiceRuntimeConfig,
|
||||
};
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires the fixture image from tests/root_logging.Dockerfile and the production capability profile"]
|
||||
fn root_appends_to_existing_logs_without_changing_ownership() {
|
||||
if let Ok(scenario) = std::env::var("AETHER_TEST_ROOT_LOGGING_CASE") {
|
||||
run_scenario(&scenario);
|
||||
return;
|
||||
}
|
||||
|
||||
for entrypoint in ["standard", "reloadable"] {
|
||||
for destination in ["file", "both"] {
|
||||
for format in ["pretty", "json"] {
|
||||
for owner in ["0", "1000", "65532", "new"] {
|
||||
let scenario = format!("{entrypoint}-{destination}-{format}-{owner}");
|
||||
let output = Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args([
|
||||
"--ignored",
|
||||
"--exact",
|
||||
"root_appends_to_existing_logs_without_changing_ownership",
|
||||
"--nocapture",
|
||||
])
|
||||
.env("AETHER_TEST_ROOT_LOGGING_CASE", &scenario)
|
||||
.env_remove("RUST_LOG")
|
||||
.output()
|
||||
.expect("root logging subprocess");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(output.status.success(), "{scenario}: {stdout}\n{stderr}");
|
||||
assert_eq!(
|
||||
stdout.matches("root logging ready").count(),
|
||||
usize::from(destination == "both"),
|
||||
"{scenario}: {stdout}"
|
||||
);
|
||||
assert!(!stderr.contains("stdout logging"), "{scenario}: {stderr}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_scenario(scenario: &str) {
|
||||
assert_eq!(unsafe { libc::geteuid() }, 0);
|
||||
assert_eq!(unsafe { libc::getegid() }, 0);
|
||||
let process_status = fs::read_to_string("/proc/self/status").expect("process status");
|
||||
for capability in ["CapEff", "CapPrm", "CapBnd"] {
|
||||
let value = process_status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix(&format!("{capability}:")))
|
||||
.expect("capability field");
|
||||
assert_eq!(
|
||||
u64::from_str_radix(value.trim(), 16).expect("capability bits"),
|
||||
0xa
|
||||
);
|
||||
}
|
||||
assert!(process_status.lines().any(|line| {
|
||||
line.strip_prefix("NoNewPrivs:")
|
||||
.is_some_and(|value| value.trim() == "1")
|
||||
}));
|
||||
|
||||
let parts: Vec<_> = scenario.split('-').collect();
|
||||
let [entrypoint, destination, format, owner] = parts.as_slice() else {
|
||||
panic!("invalid scenario: {scenario}");
|
||||
};
|
||||
let dir = PathBuf::from("/logs").join(scenario);
|
||||
let bucket = chrono::Local::now().format("%Y-%m-%d");
|
||||
let log_file = dir.join(format!("root-logging-test.{bucket}.log"));
|
||||
let directory_metadata = fs::metadata(&dir).expect("pre-existing foreign-owned directory");
|
||||
assert_eq!(directory_metadata.uid(), 1000);
|
||||
assert_eq!(directory_metadata.gid(), 1000);
|
||||
assert_eq!(directory_metadata.permissions().mode() & 0o777, 0o750);
|
||||
|
||||
let expected_owner = if *owner == "new" {
|
||||
assert!(!log_file.exists());
|
||||
0
|
||||
} else {
|
||||
let owner_uid: u32 = owner.parse().expect("fixture owner");
|
||||
let metadata = fs::metadata(&log_file).expect("pre-existing log");
|
||||
assert_eq!(metadata.uid(), owner_uid);
|
||||
assert_eq!(metadata.gid(), owner_uid);
|
||||
assert_eq!(metadata.permissions().mode() & 0o777, 0o640);
|
||||
owner_uid
|
||||
};
|
||||
|
||||
let config = ServiceRuntimeConfig::new("root-logging-test", "info")
|
||||
.with_log_destination(match *destination {
|
||||
"file" => LogDestination::File,
|
||||
"both" => LogDestination::Both,
|
||||
other => panic!("unknown destination: {other}"),
|
||||
})
|
||||
.with_log_format(match *format {
|
||||
"pretty" => LogFormat::Pretty,
|
||||
"json" => LogFormat::Json,
|
||||
other => panic!("unknown format: {other}"),
|
||||
})
|
||||
.with_file_logging(FileLoggingConfig::new(&dir, LogRotation::Daily, 7, 30));
|
||||
|
||||
let _reloader = match *entrypoint {
|
||||
"standard" => {
|
||||
init_service_runtime(config).expect("root should initialize file logging");
|
||||
None
|
||||
}
|
||||
"reloadable" => Some(
|
||||
init_reloadable_service_tracing("info", config)
|
||||
.expect("root should initialize reloadable file logging"),
|
||||
),
|
||||
other => panic!("unknown entrypoint: {other}"),
|
||||
};
|
||||
tracing::info!("root logging ready");
|
||||
|
||||
let metadata = fs::metadata(&log_file).expect("written log file");
|
||||
assert_eq!(metadata.uid(), expected_owner);
|
||||
assert_eq!(metadata.gid(), expected_owner);
|
||||
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
|
||||
let contents = fs::read_to_string(&log_file).expect("log contents");
|
||||
assert_eq!(contents.matches("root logging ready").count(), 1);
|
||||
if *owner != "new" {
|
||||
assert!(contents.starts_with("historical log\n"));
|
||||
}
|
||||
if *format == "json" {
|
||||
let event: serde_json::Value =
|
||||
serde_json::from_str(contents.lines().last().expect("log event"))
|
||||
.expect("JSON file log");
|
||||
assert_eq!(event["fields"]["message"], "root logging ready");
|
||||
}
|
||||
let directory_metadata = fs::metadata(&dir).expect("log directory");
|
||||
assert_eq!(directory_metadata.uid(), 1000);
|
||||
assert_eq!(directory_metadata.gid(), 1000);
|
||||
assert_eq!(directory_metadata.permissions().mode() & 0o777, 0o750);
|
||||
}
|
||||
@@ -58,10 +58,13 @@ services:
|
||||
app:
|
||||
image: ${APP_IMAGE:-ghcr.io/fawney19/aether:latest}
|
||||
container_name: aether-app
|
||||
user: "${AETHER_CONTAINER_UID:-65532}:${AETHER_CONTAINER_GID:-65532}"
|
||||
user: "0:0"
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- DAC_OVERRIDE
|
||||
- FOWNER
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
|
||||
+4
-1
@@ -58,10 +58,13 @@ services:
|
||||
app:
|
||||
image: ${APP_IMAGE:-ghcr.io/fawney19/aether:latest}
|
||||
container_name: aether-app
|
||||
user: "${AETHER_CONTAINER_UID:-65532}:${AETHER_CONTAINER_GID:-65532}"
|
||||
user: "0:0"
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- DAC_OVERRIDE
|
||||
- FOWNER
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 历史权限空值升级修复
|
||||
|
||||
## 原因
|
||||
|
||||
旧版 API Key、用户、用户组及上游 Key 允许把 JSON 字面量 `null`、字符串 `"null"`(忽略大小写和首尾空白)以及空字符串作为未设置的权限。严格权限读取启用后,这些值会触发 `contains JSON null; use SQL NULL for an unset policy` 等错误,影响 API Key 鉴权、列表和用户、用户组读取。管理令牌的 IP 限制和权限也曾接受 JSON 字面量 `null`,但不接受字符串空值;严格校验同样会使这些旧令牌失效。
|
||||
|
||||
增量迁移 `20260908000000_normalize_legacy_policy_nulls.sql` 将这些已知的旧版空值转换成 SQL `NULL`,不修改既有迁移及其校验和。新安装和已有数据库升级均通过同一迁移机制执行。
|
||||
|
||||
## 覆盖范围
|
||||
|
||||
| 表 | 字段 |
|
||||
| --- | --- |
|
||||
| `api_keys` | `allowed_providers`、`allowed_api_formats`、`allowed_models`、`ip_rules` |
|
||||
| `users` | `allowed_providers`、`allowed_api_formats`、`allowed_models` |
|
||||
| `user_groups` | `allowed_providers`、`allowed_api_formats`、`allowed_models` |
|
||||
| `provider_api_keys` | `api_formats`、`allowed_models` |
|
||||
| `management_tokens` | `allowed_ips`、`permissions`(仅 JSON 字面量 `null`) |
|
||||
|
||||
共 5 张表、14 个字段,同时兼容 `json` 和 `jsonb` 列。迁移可重复执行,且只更新命中旧版空值的字段:
|
||||
|
||||
- 保留 SQL `NULL`、空数组 `[]`、正常名单、字符串化的名单和单字符串策略。
|
||||
- 保留 `specific`、`deny_all`、`inherit` 等权限模式,不修改用户组成员关系。
|
||||
- 管理令牌只转换 JSON 字面量 `null`,恢复旧版既有的未设置 IP 限制或 `legacy_full` 权限语义;字符串 `"null"`、空字符串、空数组和其他非法权限不转换,避免把原本无效的令牌权限变成旧版全权限。
|
||||
- 不清理数组内部的 `null`、空白元素、数字、对象或其他异常权限;它们仍由严格读取逻辑拒绝,不能借迁移变成无限制访问。
|
||||
- 不修改其他 JSON 字段,例如 `metadata` 内的 JSON `null`。
|
||||
|
||||
## 升级方式
|
||||
|
||||
先备份数据库,部署包含此迁移的新网关二进制或镜像,并保留原来的数据库连接配置与密钥。仅重启不包含此迁移的旧版本不会修复数据。
|
||||
|
||||
默认 `AETHER_GATEWAY_DATABASE_MODE=auto` 会在启动时执行挂起迁移,再进入正常服务。使用 `verify-only` 的部署需在相同数据库连接配置下先执行新版本的准备命令,再重启服务:
|
||||
|
||||
```sh
|
||||
aether-gateway db prepare
|
||||
```
|
||||
|
||||
迁移只更新上述权限列,不删除业务记录、不替换用户或 Key、不重建数据库。不要把空数组或任意异常 JSON 统一改成 SQL `NULL`,也不要通过关闭严格权限校验绕过问题。
|
||||
|
||||
升级后可以只读检查迁移记录:
|
||||
|
||||
```sql
|
||||
SELECT version, description, success
|
||||
FROM public._sqlx_migrations
|
||||
WHERE version = 20260908000000;
|
||||
```
|
||||
|
||||
该记录应存在且 `success = true`。若仍有权限解码错误,核对实际报错实例连接的数据库,以及是否有旧进程或外部工具继续写入旧格式;不要将其他类型的权限错误直接作为空值清除。
|
||||
-42
@@ -48,10 +48,6 @@ COMPOSE_LOG_ROTATION_DEFAULT="daily"
|
||||
COMPOSE_LOG_RETENTION_DAYS_DEFAULT="7"
|
||||
COMPOSE_LOG_MAX_FILES_DEFAULT="30"
|
||||
COMPOSE_APP_PORT_DEFAULT="8084"
|
||||
COMPOSE_CONTAINER_UID_DEFAULT="65532"
|
||||
COMPOSE_CONTAINER_GID_DEFAULT="65532"
|
||||
COMPOSE_CONTAINER_UID=""
|
||||
COMPOSE_CONTAINER_GID=""
|
||||
COMPOSE_CLI=()
|
||||
LAUNCHD_LABEL="${AETHER_LAUNCHD_LABEL:-com.aether.gateway}"
|
||||
LAUNCHD_LOG_DIR="${AETHER_LAUNCHD_LOG_DIR:-/var/log/aether}"
|
||||
@@ -1764,41 +1760,6 @@ compose_app_port() {
|
||||
printf '%s\n' "${APP_PORT:-${COMPOSE_APP_PORT_DEFAULT}}"
|
||||
}
|
||||
|
||||
validate_compose_container_id() {
|
||||
local label="$1"
|
||||
local value="$2"
|
||||
|
||||
[[ "${value}" =~ ^[1-9][0-9]*$ ]] \
|
||||
|| die "${label} must be a positive numeric id, not root: ${value}"
|
||||
[[ ${#value} -le 10 ]] \
|
||||
|| die "${label} is outside the supported numeric id range: ${value}"
|
||||
(( 10#${value} <= 2147483647 )) \
|
||||
|| die "${label} is outside the supported numeric id range: ${value}"
|
||||
}
|
||||
|
||||
resolve_new_compose_container_identity() {
|
||||
local uid="${AETHER_CONTAINER_UID:-}"
|
||||
local gid="${AETHER_CONTAINER_GID:-}"
|
||||
|
||||
if [[ -z "${uid}" || -z "${gid}" ]]; then
|
||||
if [[ "${EUID}" -eq 0 && -n "${SUDO_UID:-}" && -n "${SUDO_GID:-}" ]]; then
|
||||
uid="${uid:-${SUDO_UID}}"
|
||||
gid="${gid:-${SUDO_GID}}"
|
||||
elif [[ "${EUID}" -ne 0 ]]; then
|
||||
uid="${uid:-$(id -u)}"
|
||||
gid="${gid:-$(id -g)}"
|
||||
else
|
||||
uid="${uid:-${COMPOSE_CONTAINER_UID_DEFAULT}}"
|
||||
gid="${gid:-${COMPOSE_CONTAINER_GID_DEFAULT}}"
|
||||
fi
|
||||
fi
|
||||
|
||||
validate_compose_container_id "AETHER_CONTAINER_UID" "${uid}"
|
||||
validate_compose_container_id "AETHER_CONTAINER_GID" "${gid}"
|
||||
COMPOSE_CONTAINER_UID="$((10#${uid}))"
|
||||
COMPOSE_CONTAINER_GID="$((10#${gid}))"
|
||||
}
|
||||
|
||||
append_compose_log_env_defaults() {
|
||||
local output="$1"
|
||||
replace_or_append_env "${output}" "AETHER_LOG_DESTINATION" "${COMPOSE_LOG_DESTINATION_DEFAULT}"
|
||||
@@ -1817,13 +1778,10 @@ generate_compose_env() {
|
||||
encryption_key="$(urlsafe_rand 32)"
|
||||
db_password="$(urlsafe_rand 32)"
|
||||
redis_password="$(urlsafe_rand 32)"
|
||||
resolve_new_compose_container_identity
|
||||
|
||||
cp "${COMPOSE_DIR}/.env.example" "${output}"
|
||||
replace_or_append_env "${output}" "APP_IMAGE" "$(compose_image)"
|
||||
replace_or_append_env "${output}" "APP_PORT" "$(compose_app_port)"
|
||||
replace_or_append_env "${output}" "AETHER_CONTAINER_UID" "${COMPOSE_CONTAINER_UID}"
|
||||
replace_or_append_env "${output}" "AETHER_CONTAINER_GID" "${COMPOSE_CONTAINER_GID}"
|
||||
replace_or_append_env "${output}" "DB_PASSWORD" "${db_password}"
|
||||
replace_or_append_env "${output}" "REDIS_PASSWORD" "${redis_password}"
|
||||
replace_or_append_env "${output}" "JWT_SECRET_KEY" "${JWT_SECRET_KEY:-${jwt_key}}"
|
||||
|
||||
@@ -33,21 +33,31 @@ with tempfile.TemporaryDirectory(prefix="aether-compose-databases-") as director
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
|
||||
env_file.write_text("DB_PASSWORD=fixture-postgres\nREDIS_PASSWORD=fixture-redis\n")
|
||||
for files in (
|
||||
database_environment = "DB_PASSWORD=fixture-postgres\nREDIS_PASSWORD=fixture-redis\n"
|
||||
standard_compose_files = (
|
||||
["docker-compose.yml"],
|
||||
["docker-compose.yml", "docker-compose.local.yml"],
|
||||
["docker-compose.single-node.yml"],
|
||||
):
|
||||
)
|
||||
env_file.write_text(database_environment)
|
||||
for files in standard_compose_files:
|
||||
result = compose_config(files)
|
||||
assert result.returncode == 0, result.stderr
|
||||
config = json.loads(result.stdout)
|
||||
assert set(config["services"]) == {"app", "postgres", "redis"}
|
||||
assert set(config["volumes"]) == {"postgres_data"}
|
||||
app_env = config["services"]["app"]["environment"]
|
||||
app = config["services"]["app"]
|
||||
assert app["user"] == "0:0"
|
||||
assert app["read_only"] is True
|
||||
assert app["cap_drop"] == ["ALL"]
|
||||
assert set(app["cap_add"]) == {"DAC_OVERRIDE", "FOWNER"}
|
||||
assert app["security_opt"] == ["no-new-privileges:true"]
|
||||
assert not app.get("privileged", False)
|
||||
app_env = app["environment"]
|
||||
assert app_env["AETHER_DATABASE_DRIVER"] == "postgres"
|
||||
assert app_env["DATABASE_URL"] == "postgresql://postgres:fixture-postgres@postgres:5432/aether"
|
||||
assert app_env["REDIS_URL"] == "redis://:fixture-redis@redis:6379/0"
|
||||
assert app_env["AETHER_LOG_DESTINATION"] == "stdout"
|
||||
for key in ("DB_PASSWORD", "REDIS_PASSWORD"):
|
||||
result = compose_config(files, overrides={key: ""})
|
||||
assert result.returncode != 0, f"empty {key} was accepted"
|
||||
@@ -65,4 +75,23 @@ with tempfile.TemporaryDirectory(prefix="aether-compose-databases-") as director
|
||||
assert result.returncode != 0, "empty DB_PASSWORD was accepted"
|
||||
assert "set DB_PASSWORD in .env" in result.stderr, result.stderr
|
||||
|
||||
print("PASS: PostgreSQL/Redis Compose configurations")
|
||||
for log_destination in ("file", "both"):
|
||||
env_file.write_text(
|
||||
database_environment
|
||||
+ f"AETHER_LOG_DESTINATION={log_destination}\nAETHER_LOG_DIR=/app/logs\n"
|
||||
+ "AETHER_CONTAINER_UID=65532\nAETHER_CONTAINER_GID=65532\n"
|
||||
)
|
||||
for files in standard_compose_files:
|
||||
result = compose_config(files)
|
||||
assert result.returncode == 0, result.stderr
|
||||
app = json.loads(result.stdout)["services"]["app"]
|
||||
assert app["user"] == "0:0", files
|
||||
assert app["environment"]["AETHER_LOG_DESTINATION"] == "stdout", files
|
||||
assert all(
|
||||
volume["target"] not in ("/app/logs", "/opt/aether/logs")
|
||||
for volume in app.get("volumes", [])
|
||||
), files
|
||||
assert app["logging"]["driver"] == "json-file"
|
||||
assert app["logging"]["options"] == {"max-size": "100m", "max-file": "10"}
|
||||
|
||||
print("PASS: PostgreSQL/Redis Compose configurations and legacy file logging overrides")
|
||||
|
||||
@@ -42,25 +42,23 @@ COMPOSE_FILES=(
|
||||
"${REPO_ROOT}/docker-compose.single-node.yml"
|
||||
)
|
||||
|
||||
assert_line "${APP_DOCKERFILE}" "USER 65532:65532"
|
||||
assert_line "${APP_DOCKERFILE}" "USER 0:0"
|
||||
assert_line "${REPO_ROOT}/Dockerfile.app.local" "USER 0:0"
|
||||
assert_line "${REPO_ROOT}/Dockerfile.app.release-local" "USER 0:0"
|
||||
assert_line "${APP_DOCKERFILE}" " HOME=/tmp/aether-home \\"
|
||||
if grep -Eq '^USER[[:space:]]+(root|0)(:0)?[[:space:]]*$' "${APP_DOCKERFILE}"; then
|
||||
fail_test "production image still selects a root runtime identity"
|
||||
fi
|
||||
|
||||
for compose_file in "${COMPOSE_FILES[@]}"; do
|
||||
assert_line "${compose_file}" \
|
||||
' user: "${AETHER_CONTAINER_UID:-65532}:${AETHER_CONTAINER_GID:-65532}"'
|
||||
assert_line "${compose_file}" ' user: "0:0"'
|
||||
assert_line "${compose_file}" " read_only: true"
|
||||
assert_line "${compose_file}" " cap_drop:"
|
||||
assert_line "${compose_file}" " - ALL"
|
||||
assert_line "${compose_file}" " cap_add:"
|
||||
assert_line "${compose_file}" " - DAC_OVERRIDE"
|
||||
assert_line "${compose_file}" " - FOWNER"
|
||||
assert_line "${compose_file}" " security_opt:"
|
||||
assert_line "${compose_file}" " - no-new-privileges:true"
|
||||
assert_line "${compose_file}" " tmpfs:"
|
||||
assert_line "${compose_file}" " - /tmp:rw,nosuid,nodev,noexec,mode=1777"
|
||||
if grep -Eq '^[[:space:]]+user:[[:space:]]+"?(root|0)(:0)?"?[[:space:]]*$' "${compose_file}"; then
|
||||
fail_test "production Compose file still selects a root runtime identity: ${compose_file}"
|
||||
fi
|
||||
done
|
||||
|
||||
assert_line "${REPO_ROOT}/.env.example" "DB_PASSWORD="
|
||||
@@ -84,19 +82,16 @@ mkdir -p "${fake_bin}"
|
||||
printf '#!/usr/bin/env bash\nprintf "false\\n"\n' >"${fake_bin}/docker"
|
||||
chmod 0755 "${fake_bin}/docker"
|
||||
PATH="${fake_bin}:${PATH}"
|
||||
fixture_uid="$(id -u)"
|
||||
fixture_gid="$(id -g)"
|
||||
[[ "${fixture_uid}" != "0" ]] || fixture_uid="65532"
|
||||
[[ "${fixture_gid}" != "0" ]] || fixture_gid="65532"
|
||||
cp "${REPO_ROOT}/.env.example" "${COMPOSE_DIR}/.env.example"
|
||||
ADMIN_PASSWORD="test-admin-password"
|
||||
APP_IMAGE="example.invalid/aether:test"
|
||||
AETHER_CONTAINER_UID="${fixture_uid}"
|
||||
AETHER_CONTAINER_GID="${fixture_gid}"
|
||||
JWT_SECRET_KEY=""
|
||||
ENCRYPTION_KEY=""
|
||||
generated_env="${TEST_ROOT}/generated.env"
|
||||
generate_compose_env "${generated_env}"
|
||||
if grep -Eq '^AETHER_CONTAINER_(UID|GID)=' "${generated_env}"; then
|
||||
fail_test "installer still generates obsolete non-root container identity settings"
|
||||
fi
|
||||
|
||||
generated_secrets=()
|
||||
for key in \
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
COPY root-logging-tests /usr/local/bin/root-logging-tests
|
||||
|
||||
RUN set -eu; \
|
||||
bucket="$(date -u +%Y-%m-%d)"; \
|
||||
for entrypoint in standard reloadable; do \
|
||||
for destination in file both; do \
|
||||
for format in pretty json; do \
|
||||
for owner in 0 1000 65532 new; do \
|
||||
directory="/logs/${entrypoint}-${destination}-${format}-${owner}"; \
|
||||
mkdir -p "${directory}"; \
|
||||
chown 1000:1000 "${directory}"; \
|
||||
chmod 0750 "${directory}"; \
|
||||
if [ "${owner}" != new ]; then \
|
||||
logfile="${directory}/root-logging-test.${bucket}.log"; \
|
||||
printf 'historical log\n' >"${logfile}"; \
|
||||
chown "${owner}:${owner}" "${logfile}"; \
|
||||
chmod 0640 "${logfile}"; \
|
||||
fi; \
|
||||
done; \
|
||||
done; \
|
||||
done; \
|
||||
done
|
||||
|
||||
USER 0:0
|
||||
VOLUME ["/logs"]
|
||||
ENTRYPOINT ["/usr/local/bin/root-logging-tests", "--ignored", "--exact", "root_appends_to_existing_logs_without_changing_ownership", "--nocapture"]
|
||||
Reference in New Issue
Block a user