feat: 支持 Portal YAML 配置监听地址 (#74)

This commit is contained in:
ila
2026-08-28 23:16:12 +08:00
parent 588f09be53
commit f354d53ba6
6 changed files with 92 additions and 9 deletions
+12 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -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 不在统一配置重复保存,避免两个事实源漂移。
从仓库根目录执行:
+4 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -119,10 +119,11 @@ MVP-0 的终端用户会话保存在单个 portal 进程内存中,Cookie 只
上传先在应用层校验,再在 generation 事务回调中保存带 owner/generation metadata 的文件。事务失败只清理本请求保存的 key;存储根目录不能作为 nginx/static 目录暴露。
## MVP-0 worker 已实现配置与退出行为
portal 单二进制会同时启动 HTTP server 和内嵌 worker。启动必须显式传入 `--config <settings.yml>`。下列 Provider/worker 参数以 YAML 为基础值,同名环境变量可逐项覆盖:
portal 单二进制会同时启动 HTTP server 和内嵌 worker。启动必须显式传入 `--config <settings.yml>`。监听地址、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 总超时和响应头超时 |
+27 -3
View File
@@ -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
+30
View File
@@ -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")
+16
View File
@@ -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")
}
+3
View File
@@ -1,5 +1,8 @@
schema_version: 1
server:
listen_address: 127.0.0.1:8080
provider:
http_timeout_seconds: 45
max_response_bytes: 33554432