From f354d53ba6f6607750f5bad70ae1e7277fc48135 Mon Sep 17 00:00:00 2001 From: ila Date: Fri, 28 Aug 2026 23:16:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20Portal=20YAML=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=9B=91=E5=90=AC=E5=9C=B0=E5=9D=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/04-local-development-and-verification.md | 15 ++++++++-- docs/10-deployment-and-operations.md | 7 +++-- internal/config/config.go | 30 +++++++++++++++++-- internal/config/config_test.go | 30 +++++++++++++++++++ internal/config/file.go | 16 ++++++++++ portal/config/settings.example.yml | 3 ++ 6 files changed, 92 insertions(+), 9 deletions(-) diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 6d94d04..85c1234 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Local-Development-and-Verification.- -wiki_revision: 7e5263ea3ca25119464e10b1afcda12e9582229c -synchronized_at: 2026-08-28T13:26:42Z +wiki_revision: 50810cebc9b77723c4cbcb467786f12915a09b57 +synchronized_at: 2026-08-28T15:09:16Z # 本地开发与验证 @@ -212,6 +212,15 @@ scripts\chorus-dev.bat stop go run ./portal --config portal/config/settings.yml ``` +独立运行或 Windows 解压包可直接在 Portal settings 中设置端口: + +```yaml +server: + listen_address: 127.0.0.1:8080 +``` + +环境变量 `CHORUS_LISTEN_ADDRESS` 可覆盖该值;非法 `host:port`、空 host、端口 0 或超过 65535 会在 HTTP server 启动前被拒绝。 + 浏览器验证: 1. 用种子用户登录,不应出现公开注册链接; @@ -370,7 +379,7 @@ admin_ui: port: 9528 ``` -相对路径以 `local-services.yml` 所在目录为基准。Portal `environment_file` 保存数据库、Session 等敏感环境变量;`settings_file` 保存 Provider/worker 运维参数,环境变量优先覆盖 YAML。配置严格拒绝未知字段、非 loopback Host、无效/重复端口、缺失文件或错误扩展名。`chorus-local-config` 读取 Admin settings 时只投影 `settings.application.host/port`,不会输出其他配置内容。Admin Host/Port 不在统一配置重复保存,避免两个事实源漂移。 +相对路径以 `local-services.yml` 所在目录为基准。Portal `environment_file` 保存数据库、Session 等敏感环境变量;`settings_file` 保存 `server.listen_address`、Provider 和 worker 运维参数。监听地址优先级为 `CHORUS_LISTEN_ADDRESS` 环境变量 > YAML `server.listen_address` > 默认 `127.0.0.1:8080`;统一启动脚本仍以 `local-services.yml` 的 Portal Host/Port 显式覆盖 YAML,确保三实例端口只有一个运行事实源。配置严格拒绝未知字段、非 loopback Host、无效/重复端口、缺失文件或错误扩展名。`chorus-local-config` 读取 Admin settings 时只投影 `settings.application.host/port`,不会输出其他配置内容。Admin Host/Port 不在统一配置重复保存,避免两个事实源漂移。 从仓库根目录执行: diff --git a/docs/10-deployment-and-operations.md b/docs/10-deployment-and-operations.md index 69976c5..2a26b7f 100644 --- a/docs/10-deployment-and-operations.md +++ b/docs/10-deployment-and-operations.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Deployment-and-Operations.- -wiki_revision: a55afb67eb65d59803990015ac8634af786cd4e2 -synchronized_at: 2026-08-27T01:59:02Z +wiki_revision: fe7f8d0ca98f6c2fbf137316afe833b7f2c1f629 +synchronized_at: 2026-08-28T15:10:45Z # 部署与运维 @@ -119,10 +119,11 @@ MVP-0 的终端用户会话保存在单个 portal 进程内存中,Cookie 只 上传先在应用层校验,再在 generation 事务回调中保存带 owner/generation metadata 的文件。事务失败只清理本请求保存的 key;存储根目录不能作为 nginx/static 目录暴露。 ## MVP-0 worker 已实现配置与退出行为 -portal 单二进制会同时启动 HTTP server 和内嵌 worker。启动必须显式传入 `--config `。下列 Provider/worker 参数以 YAML 为基础值,同名环境变量可逐项覆盖: +portal 单二进制会同时启动 HTTP server 和内嵌 worker。启动必须显式传入 `--config `。监听地址、Provider 和 worker 参数以 YAML 为基础值,同名环境变量可逐项覆盖;监听地址未在 YAML 或环境变量中设置时默认为 `127.0.0.1:8080`: | YAML 字段 | 环境覆盖变量 | 约束 | |---|---|---| +| `server.listen_address` | `CHORUS_LISTEN_ADDRESS` | `host:port`;端口范围 1–65535;示例和默认值仅监听 loopback | | `worker.lease_seconds` | `CHORUS_WORKER_LEASE_SECONDS` | 正整数;必须大于 Provider HTTP timeout + 5 | | `worker.poll_milliseconds` | `CHORUS_WORKER_POLL_MILLISECONDS` | 正整数;控制空队列轮询间隔 | | `provider.http_timeout_seconds` | `CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS` | 正整数;安全 HTTP client 总超时和响应头超时 | diff --git a/internal/config/config.go b/internal/config/config.go index f3e9cc3..5e62363 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "errors" "fmt" + "net" "os" "strconv" "strings" @@ -82,10 +83,18 @@ func loadFromLookup(lookup func(string) (string, bool), defaults runtimeSettings return Config{}, fmt.Errorf("CHORUS_ENV must be development, test, or production") } + listenAddress := read("CHORUS_LISTEN_ADDRESS") + if listenAddress == "" && defaults.Server.ListenAddress != nil { + listenAddress = strings.TrimSpace(*defaults.Server.ListenAddress) + } + if listenAddress == "" && defaults.Server.ListenAddress == nil { + listenAddress = "127.0.0.1:8080" + } + cfg := Config{ Environment: environment, DBDSN: read("CHORUS_DSN"), - ListenAddress: read("CHORUS_LISTEN_ADDRESS"), + ListenAddress: listenAddress, StorageRoot: read("CHORUS_STORAGE_ROOT"), SessionKey: read("CHORUS_SESSION_KEY"), } @@ -101,8 +110,8 @@ func loadFromLookup(lookup func(string) (string, bool), defaults runtimeSettings cfg.SessionKey = base64.RawURLEncoding.EncodeToString(generated) } } - if cfg.ListenAddress == "" { - cfg.ListenAddress = "127.0.0.1:8080" + if err := validateListenAddress(cfg.ListenAddress); err != nil { + return Config{}, err } var missing []string @@ -223,6 +232,21 @@ func loadFromLookup(lookup func(string) (string, bool), defaults runtimeSettings return cfg, nil } +func validateListenAddress(value string) error { + host, portText, err := net.SplitHostPort(value) + if err != nil || host == "" || strings.TrimSpace(host) != host { + return errors.New("portal listen address must use host:port") + } + if portText == "" || strings.Trim(portText, "0123456789") != "" { + return errors.New("portal listen address contains an invalid port") + } + port, err := strconv.Atoi(portText) + if err != nil || port < 1 || port > 65535 { + return errors.New("portal listen address contains an invalid port") + } + return nil +} + func (c Config) ValidateProduction() error { if c.Environment != Production { return nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 41a8a1f..2cd5abb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,6 +10,8 @@ import ( func TestLoadFileUsesYAMLAndEnvironmentOverrides(t *testing.T) { path := writeSettings(t, `schema_version: 1 +server: + listen_address: 127.0.0.1:9080 provider: http_timeout_seconds: 120 max_response_bytes: 1048576 @@ -25,12 +27,16 @@ worker: if cfg.ProviderHTTPTimeout != 120*time.Second || cfg.ProviderMaxResponseBytes != 1048576 || cfg.WorkerLeaseDuration != 150*time.Second || cfg.WorkerPollInterval != 500*time.Millisecond { t.Fatalf("YAML settings were not loaded: %#v", cfg) } + if cfg.ListenAddress != "127.0.0.1:9080" { + t.Fatalf("listen address = %q", cfg.ListenAddress) + } if got := cfg.ProviderAllowedPorts; len(got) != 3 || got[2] != 8080 { t.Fatalf("provider ports = %v", got) } overrides := map[string]string{ "CHORUS_DSN": "test-dsn", + "CHORUS_LISTEN_ADDRESS": "127.0.0.1:9081", "CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS": "60", "CHORUS_PROVIDER_MAX_RESPONSE_BYTES": "2097152", "CHORUS_PROVIDER_ALLOWED_PORTS": "80,443,8443", @@ -44,6 +50,9 @@ worker: if cfg.ProviderHTTPTimeout != 60*time.Second || cfg.ProviderMaxResponseBytes != 2097152 || cfg.WorkerLeaseDuration != 90*time.Second || cfg.WorkerPollInterval != 100*time.Millisecond || cfg.ProviderAllowedPorts[2] != 8443 { t.Fatalf("environment overrides were not applied: %#v", cfg) } + if cfg.ListenAddress != "127.0.0.1:9081" { + t.Fatalf("environment listen address override = %q", cfg.ListenAddress) + } } func TestLoadFileRejectsInvalidSettings(t *testing.T) { @@ -64,6 +73,16 @@ worker: {lease_seconds: 60, poll_milliseconds: 250} "unsafe lease": `schema_version: 1 provider: {http_timeout_seconds: 60, max_response_bytes: 1024, allowed_ports: [80]} worker: {lease_seconds: 65, poll_milliseconds: 250} +`, + "empty listen address": `schema_version: 1 +server: {listen_address: ""} +provider: {http_timeout_seconds: 45, max_response_bytes: 1024, allowed_ports: [80]} +worker: {lease_seconds: 60, poll_milliseconds: 250} +`, + "invalid listen address": `schema_version: 1 +server: {listen_address: "http://127.0.0.1:8080"} +provider: {http_timeout_seconds: 45, max_response_bytes: 1024, allowed_ports: [80]} +worker: {lease_seconds: 60, poll_milliseconds: 250} `, } for name, body := range tests { @@ -75,6 +94,17 @@ worker: {lease_seconds: 65, poll_milliseconds: 250} } } +func TestLoadRejectsInvalidListenAddressEnvironmentOverride(t *testing.T) { + for _, value := range []string{"127.0.0.1", ":8080", "127.0.0.1:0", "127.0.0.1:65536"} { + _, err := LoadFromLookup(lookup(map[string]string{ + "CHORUS_DSN": "test-dsn", "CHORUS_LISTEN_ADDRESS": value, + })) + if err == nil || !strings.Contains(err.Error(), "listen address") { + t.Fatalf("invalid listen address %q was accepted: %v", value, err) + } + } +} + func writeSettings(t *testing.T, body string) string { t.Helper() path := filepath.Join(t.TempDir(), "settings.yml") diff --git a/internal/config/file.go b/internal/config/file.go index 25bc38f..a821cfd 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -16,10 +16,15 @@ const maxSettingsBytes = 1 << 20 type runtimeSettings struct { SchemaVersion int `yaml:"schema_version"` + Server serverSettings `yaml:"server"` Provider providerSettings `yaml:"provider"` Worker workerSettings `yaml:"worker"` } +type serverSettings struct { + ListenAddress *string `yaml:"listen_address"` +} + type providerSettings struct { HTTPTimeoutSeconds int `yaml:"http_timeout_seconds"` MaxResponseBytes int `yaml:"max_response_bytes"` @@ -32,8 +37,10 @@ type workerSettings struct { } func defaultRuntimeSettings() runtimeSettings { + listenAddress := "127.0.0.1:8080" return runtimeSettings{ SchemaVersion: 1, + Server: serverSettings{ListenAddress: &listenAddress}, Provider: providerSettings{ HTTPTimeoutSeconds: 45, MaxResponseBytes: 32 << 20, @@ -73,6 +80,15 @@ func loadRuntimeSettings(path string) (runtimeSettings, error) { if settings.SchemaVersion != 1 { return runtimeSettings{}, errors.New("portal settings schema_version must be 1") } + if settings.Server.ListenAddress != nil { + listenAddress := strings.TrimSpace(*settings.Server.ListenAddress) + if listenAddress != *settings.Server.ListenAddress { + return runtimeSettings{}, errors.New("portal settings server.listen_address must not contain surrounding whitespace") + } + if err := validateListenAddress(listenAddress); err != nil { + return runtimeSettings{}, err + } + } if settings.Provider.HTTPTimeoutSeconds <= 0 || settings.Provider.MaxResponseBytes <= 0 || settings.Worker.LeaseSeconds <= 0 || settings.Worker.PollMilliseconds <= 0 { return runtimeSettings{}, errors.New("portal settings values must be positive") } diff --git a/portal/config/settings.example.yml b/portal/config/settings.example.yml index 604f4d8..b50ce67 100644 --- a/portal/config/settings.example.yml +++ b/portal/config/settings.example.yml @@ -1,5 +1,8 @@ schema_version: 1 +server: + listen_address: 127.0.0.1:8080 + provider: http_timeout_seconds: 45 max_response_bytes: 33554432