MiroFish / internal /proxy /config.go
pjpjq's picture
feat(space): 部署 notion-manager 并启用密码保护
5d65746 verified
Raw
History Blame Contribute Delete
19.9 kB
package proxy
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
"notion-manager/internal/netutil"
)
// Config holds all configurable values for notion-manager.
// Priority: environment variable > config.yaml > default value.
type Config struct {
Server ServerConfig `yaml:"server"`
Proxy ProxyConfig `yaml:"proxy"`
Timeouts TimeoutConfig `yaml:"timeouts"`
Refresh RefreshConfig `yaml:"refresh"`
Browser BrowserConfig `yaml:"browser"`
Register RegisterConfig `yaml:"register"`
ModelMap map[string]string `yaml:"model_map"`
}
type ServerConfig struct {
Port string `yaml:"port"`
AccountsDir string `yaml:"accounts_dir"`
TokenFile string `yaml:"token_file"`
ApiKey string `yaml:"api_key"`
AdminPassword string `yaml:"admin_password"`
LogFile string `yaml:"log_file"`
DebugLogging bool `yaml:"debug_logging"`
APILogInput bool `yaml:"api_log_input"`
APILogOutput bool `yaml:"api_log_output"`
NotionLogReq bool `yaml:"notion_log_request"`
NotionLogResp bool `yaml:"notion_log_response"`
DumpAPIInput bool `yaml:"dump_api_input"`
}
type ProxyConfig struct {
NotionAPIBase string `yaml:"notion_api_base"`
ClientVersion string `yaml:"client_version"`
DefaultModel string `yaml:"default_model"`
DisableNotionPrompt bool `yaml:"disable_notion_prompt"`
EnableWebSearch *bool `yaml:"enable_web_search"`
EnableWorkspaceSearch *bool `yaml:"enable_workspace_search"`
// AskModeDefault toggles Notion's ASK mode (frontend "Answers only,
// won't make edits" — config.useReadOnlyMode=true on the workflow
// thread). When true, all chat requests run in read-only mode by
// default; per-request callers can still override via the model
// suffix `-ask`. nil/false = default mode (model can edit).
AskModeDefault *bool `yaml:"ask_mode_default"`
// NotionProxy is the optional upstream proxy URL routed in front of every
// outbound HTTPS request to notion.so (API calls + reverse-proxy /ai).
// Supported schemes: http, https, socks5, socks5h. Empty = direct dial.
// The dashboard exposes this field via /admin/settings so operators can
// flip the upstream without redeploying.
NotionProxy string `yaml:"notion_proxy"`
}
type TimeoutConfig struct {
InferenceTimeout int `yaml:"inference_timeout"`
ResearchTimeout int `yaml:"research_timeout"`
APITimeout int `yaml:"api_timeout"`
TLSDialTimeout int `yaml:"tls_dial_timeout"`
}
type RefreshConfig struct {
IntervalMinutes int `yaml:"interval_minutes"`
QuotaRecheckMinutes int `yaml:"quota_recheck_minutes"`
Concurrency int `yaml:"concurrency"`
// LiveCheckSeconds is the minimum interval (in seconds) between live
// per-request quota checks for the same account. A request always
// re-checks an account whose cached quota is older than this. Set to 0
// to force a live check on every request (slower but most up-to-date).
// Default: 5 seconds.
LiveCheckSeconds int `yaml:"live_check_seconds"`
}
type BrowserConfig struct {
UserAgent string `yaml:"user_agent"`
SecChUA string `yaml:"sec_ch_ua"`
SecChUAPlatform string `yaml:"sec_ch_ua_platform"`
}
// RegisterConfig governs the bulk register history JobStore. Defaults are
// applied via DefaultConfig so a missing register: section in config.yaml
// still yields a working setup.
type RegisterConfig struct {
// HistoryFile is the JSON file where completed jobs are persisted. The
// path is relative to the working directory (typically the binary's
// dir). Default: accounts/.register_history.json
HistoryFile string `yaml:"history_file"`
// HistoryMemoryCap caps how many jobs are kept in RAM (and reloaded
// from disk on startup). Default: 100.
HistoryMemoryCap int `yaml:"history_memory_cap"`
// DefaultConcurrency is the value pre-filled in the dashboard's
// register modal. The runner has no hard upper bound. Default: 1.
DefaultConcurrency int `yaml:"default_concurrency"`
}
// AppConfig is the global configuration instance
var AppConfig *Config
// DefaultConfig returns the default configuration (matching original hardcoded values)
func DefaultConfig() *Config {
return &Config{
Server: ServerConfig{
Port: "8081",
AccountsDir: "accounts",
TokenFile: "token.txt",
ApiKey: "",
LogFile: "",
DebugLogging: true,
APILogInput: false,
APILogOutput: false,
NotionLogReq: false,
NotionLogResp: false,
},
Proxy: ProxyConfig{
NotionAPIBase: "https://www.notion.so/api/v3",
ClientVersion: "23.13.20260313.1423",
DefaultModel: "opus-4.6",
EnableWebSearch: boolPtr(true),
EnableWorkspaceSearch: boolPtr(false),
AskModeDefault: boolPtr(false),
},
Timeouts: TimeoutConfig{
InferenceTimeout: 300,
ResearchTimeout: 360,
APITimeout: 30,
TLSDialTimeout: 30,
},
Refresh: RefreshConfig{
IntervalMinutes: 30,
QuotaRecheckMinutes: 30,
Concurrency: 10,
LiveCheckSeconds: 5,
},
Browser: BrowserConfig{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
SecChUA: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`,
SecChUAPlatform: `"Windows"`,
},
Register: RegisterConfig{
HistoryFile: "accounts/.register_history.json",
HistoryMemoryCap: 100,
DefaultConcurrency: 1,
},
ModelMap: map[string]string{
"opus-4.6": "avocado-froyo-medium",
"sonnet-4.6": "almond-croissant-low",
"haiku-4.5": "anthropic-haiku-4.5",
"gpt-5.2": "oatmeal-cookie",
"gpt-5.4": "oval-kumquat-medium",
"gemini-2.5-flash": "vertex-gemini-2.5-flash",
"gemini-3-flash": "gingerbread",
"minimax-m2.5": "fireworks-minimax-m2.5",
},
}
}
// LoadConfig loads configuration with priority: env > config.yaml > defaults.
// configPath is the path to config.yaml (can be empty to skip file loading).
func LoadConfig(configPath string) (*Config, error) {
cfg := DefaultConfig()
loadedFromFile := false
missingConfig := false
// Step 1: Load from config.yaml if it exists
if configPath != "" {
data, err := os.ReadFile(configPath)
if err == nil {
loadedFromFile = true
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config.yaml: %w", err)
}
// Ensure model_map is not nil after YAML parse
if cfg.ModelMap == nil {
cfg.ModelMap = DefaultConfig().ModelMap
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("read config.yaml: %w", err)
} else {
missingConfig = true
}
}
// Step 2: Environment variables override config.yaml values
if v := os.Getenv("PORT"); v != "" {
cfg.Server.Port = v
}
if v := os.Getenv("ACCOUNTS_DIR"); v != "" {
cfg.Server.AccountsDir = v
}
if v := os.Getenv("TOKEN_FILE"); v != "" {
cfg.Server.TokenFile = v
}
if v := os.Getenv("API_KEY"); v != "" {
cfg.Server.ApiKey = v
}
if v := os.Getenv("ADMIN_PASSWORD"); v != "" {
cfg.Server.AdminPassword = v
}
if v := os.Getenv("LOG_FILE"); v != "" {
cfg.Server.LogFile = v
}
if v := os.Getenv("DEBUG_LOGGING"); v != "" {
cfg.Server.DebugLogging = strings.EqualFold(v, "true") || v == "1"
}
if v := os.Getenv("API_LOG_INPUT"); v != "" {
cfg.Server.APILogInput = strings.EqualFold(v, "true") || v == "1"
}
if v := os.Getenv("API_LOG_OUTPUT"); v != "" {
cfg.Server.APILogOutput = strings.EqualFold(v, "true") || v == "1"
}
if v := os.Getenv("NOTION_LOG_REQUEST"); v != "" {
cfg.Server.NotionLogReq = strings.EqualFold(v, "true") || v == "1"
}
if v := os.Getenv("NOTION_LOG_RESPONSE"); v != "" {
cfg.Server.NotionLogResp = strings.EqualFold(v, "true") || v == "1"
}
if v := os.Getenv("DUMP_API_INPUT"); v != "" {
cfg.Server.DumpAPIInput = strings.EqualFold(v, "true") || v == "1"
}
if v := os.Getenv("NOTION_API_BASE"); v != "" {
cfg.Proxy.NotionAPIBase = v
}
if v := os.Getenv("CLIENT_VERSION"); v != "" {
cfg.Proxy.ClientVersion = v
}
if v := os.Getenv("DEFAULT_MODEL"); v != "" {
cfg.Proxy.DefaultModel = v
}
if v := os.Getenv("INFERENCE_TIMEOUT"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.Timeouts.InferenceTimeout = n
}
}
if v := os.Getenv("API_TIMEOUT"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.Timeouts.APITimeout = n
}
}
if v := os.Getenv("TLS_DIAL_TIMEOUT"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.Timeouts.TLSDialTimeout = n
}
}
if v := os.Getenv("REFRESH_INTERVAL_MINUTES"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.Refresh.IntervalMinutes = n
}
}
if v := os.Getenv("QUOTA_RECHECK_MINUTES"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.Refresh.QuotaRecheckMinutes = n
}
}
if v := os.Getenv("REFRESH_CONCURRENCY"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cfg.Refresh.Concurrency = n
}
}
if v := os.Getenv("QUOTA_LIVE_CHECK_SECONDS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
cfg.Refresh.LiveCheckSeconds = n
}
}
if v := os.Getenv("USER_AGENT"); v != "" {
cfg.Browser.UserAgent = v
}
if v := os.Getenv("SEC_CH_UA"); v != "" {
cfg.Browser.SecChUA = v
}
if v := os.Getenv("SEC_CH_UA_PLATFORM"); v != "" {
cfg.Browser.SecChUAPlatform = v
}
if v := os.Getenv("ENABLE_WEB_SEARCH"); v != "" {
b := strings.EqualFold(v, "true") || v == "1"
cfg.Proxy.EnableWebSearch = &b
}
if v := os.Getenv("ENABLE_WORKSPACE_SEARCH"); v != "" {
b := strings.EqualFold(v, "true") || v == "1"
cfg.Proxy.EnableWorkspaceSearch = &b
}
if v := os.Getenv("ASK_MODE_DEFAULT"); v != "" {
b := strings.EqualFold(v, "true") || v == "1"
cfg.Proxy.AskModeDefault = &b
}
if v := os.Getenv("NOTION_PROXY"); v != "" {
cfg.Proxy.NotionProxy = strings.TrimSpace(v)
}
// Validate the configured proxy URL once at startup; an invalid scheme
// is logged and cleared so the runtime fails closed (direct dial)
// rather than leaking the misconfigured URL into every notion request.
if cfg.Proxy.NotionProxy != "" {
if err := netutil.ValidateProxyURL(cfg.Proxy.NotionProxy); err != nil {
log.Printf("[config] invalid notion_proxy %q: %v (falling back to direct dial)", cfg.Proxy.NotionProxy, err)
cfg.Proxy.NotionProxy = ""
}
}
// Register defaults — fill any zero values that survived YAML parsing.
if cfg.Register.HistoryFile == "" {
cfg.Register.HistoryFile = "accounts/.register_history.json"
}
if cfg.Register.HistoryMemoryCap <= 0 {
cfg.Register.HistoryMemoryCap = 100
}
if cfg.Register.DefaultConcurrency <= 0 {
cfg.Register.DefaultConcurrency = 1
}
if err := ConfigureLogOutput(cfg.Server.LogFile); err != nil {
return nil, fmt.Errorf("configure log output %q: %w", cfg.Server.LogFile, err)
}
AppConfig = cfg
SetDebugLoggingEnabled(cfg.Server.DebugLogging)
SetAPILogInputEnabled(cfg.Server.APILogInput)
SetAPILogOutputEnabled(cfg.Server.APILogOutput)
SetNotionRequestLoggingEnabled(cfg.Server.NotionLogReq)
SetNotionResponseLoggingEnabled(cfg.Server.NotionLogResp)
if loadedFromFile {
log.Printf("[config] loaded from %s", configPath)
} else if missingConfig {
log.Printf("[config] %s not found, using defaults", configPath)
}
return cfg, nil
}
// GenerateApiKey generates a random 32-character hex API key (sk-xxxx format)
func GenerateApiKey() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return "sk-" + hex.EncodeToString(b)
}
// GenerateAdminPassword generates a random 16-character password for dashboard login.
func GenerateAdminPassword() string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, 16)
_, _ = rand.Read(b)
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b)
}
// AdminPasswordSalt returns the salt from a hashed admin password.
// Format: "$sha256$<salt>$<hash>"
func AdminPasswordSalt(hashed string) string {
parts := strings.SplitN(hashed, "$", 4)
if len(parts) == 4 && parts[1] == "sha256" {
return parts[2]
}
return ""
}
// AdminPasswordHash returns the hash from a hashed admin password.
func AdminPasswordHash(hashed string) string {
parts := strings.SplitN(hashed, "$", 4)
if len(parts) == 4 && parts[1] == "sha256" {
return parts[3]
}
return ""
}
// IsAdminPasswordHashed checks if the password is already hashed.
func IsAdminPasswordHashed(pw string) bool {
return strings.HasPrefix(pw, "$sha256$")
}
// HashAdminPassword hashes a plaintext password with a random salt.
func HashAdminPassword(plaintext string) string {
salt := make([]byte, 16)
_, _ = rand.Read(salt)
saltHex := hex.EncodeToString(salt)
h := sha256.Sum256([]byte(saltHex + plaintext))
return "$sha256$" + saltHex + "$" + hex.EncodeToString(h[:])
}
// VerifyAdminPassword checks if a SHA256 hash matches the stored hash.
// The client sends SHA256(salt + password); server compares directly.
func VerifyAdminPassword(storedHash, clientHash string) bool {
expected := AdminPasswordHash(storedHash)
return expected != "" && strings.EqualFold(expected, clientHash)
}
// EnsureAdminPassword hashes the admin password on first run and writes it back.
func EnsureAdminPassword(cfg *Config, configPath string) {
if cfg.Server.AdminPassword == "" || IsAdminPasswordHashed(cfg.Server.AdminPassword) {
return
}
// Hash the plaintext password
hashed := HashAdminPassword(cfg.Server.AdminPassword)
cfg.Server.AdminPassword = hashed
log.Printf("[config] admin_password hashed (SHA256+salt)")
// Write back to config.yaml using YAML node manipulation
if configPath == "" {
return
}
data, err := os.ReadFile(configPath)
if err != nil {
return
}
var root yaml.Node
if err := yaml.Unmarshal(data, &root); err != nil || root.Kind == 0 {
return
}
if root.Kind == yaml.DocumentNode && len(root.Content) > 0 {
mapping := root.Content[0]
for i := 0; i < len(mapping.Content)-1; i += 2 {
if mapping.Content[i].Value == "server" {
serverNode := mapping.Content[i+1]
found := false
for j := 0; j < len(serverNode.Content)-1; j += 2 {
if serverNode.Content[j].Value == "admin_password" {
serverNode.Content[j+1].Value = hashed
serverNode.Content[j+1].Tag = "!!str"
serverNode.Content[j+1].Style = yaml.DoubleQuotedStyle
found = true
break
}
}
if !found {
serverNode.Content = append(serverNode.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: "admin_password"},
&yaml.Node{Kind: yaml.ScalarNode, Value: hashed, Tag: "!!str", Style: yaml.DoubleQuotedStyle},
)
}
break
}
}
}
out, err := yaml.Marshal(&root)
if err != nil {
return
}
os.WriteFile(configPath, out, 0644)
}
// EnsureApiKey checks if an API key is configured. If not, generates one and
// writes it back to config.yaml so it persists across restarts.
func EnsureApiKey(cfg *Config, configPath string) {
if cfg.Server.ApiKey != "" {
return
}
cfg.Server.ApiKey = GenerateApiKey()
log.Printf("[config] no api_key configured, generated: %s", cfg.Server.ApiKey)
// Write the key back to config.yaml
if configPath == "" {
return
}
// Read existing file or start fresh
var root yaml.Node
data, err := os.ReadFile(configPath)
if err == nil {
if err := yaml.Unmarshal(data, &root); err != nil {
log.Printf("[config] failed to parse %s for write-back: %v", configPath, err)
return
}
}
// If parsing failed or file is empty, write a minimal config
if root.Kind == 0 {
minimal := fmt.Sprintf("server:\n api_key: \"%s\"\n", cfg.Server.ApiKey)
if err := os.WriteFile(configPath, []byte(minimal), 0644); err != nil {
log.Printf("[config] failed to write %s: %v", configPath, err)
}
return
}
// Find or create server.api_key in the YAML tree
if root.Kind == yaml.DocumentNode && len(root.Content) > 0 {
mapping := root.Content[0]
var serverNode *yaml.Node
for i := 0; i < len(mapping.Content)-1; i += 2 {
if mapping.Content[i].Value == "server" {
serverNode = mapping.Content[i+1]
break
}
}
if serverNode == nil {
// Add server section
mapping.Content = append(mapping.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: "server"},
&yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Value: "api_key"},
{Kind: yaml.ScalarNode, Value: cfg.Server.ApiKey, Tag: "!!str"},
}},
)
} else {
// Find or add api_key in server
found := false
for i := 0; i < len(serverNode.Content)-1; i += 2 {
if serverNode.Content[i].Value == "api_key" {
serverNode.Content[i+1].Value = cfg.Server.ApiKey
serverNode.Content[i+1].Tag = "!!str"
found = true
break
}
}
if !found {
serverNode.Content = append(serverNode.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: "api_key"},
&yaml.Node{Kind: yaml.ScalarNode, Value: cfg.Server.ApiKey, Tag: "!!str"},
)
}
}
}
out, err := yaml.Marshal(&root)
if err != nil {
log.Printf("[config] failed to marshal config: %v", configPath)
return
}
if err := os.WriteFile(configPath, out, 0644); err != nil {
log.Printf("[config] failed to write %s: %v", configPath, err)
}
}
// Helper methods for convenient access to typed timeout values
func (c *Config) InferenceTimeoutDuration() time.Duration {
return time.Duration(c.Timeouts.InferenceTimeout) * time.Second
}
func (c *Config) ResearchTimeoutDuration() time.Duration {
if c.Timeouts.ResearchTimeout <= 0 {
return 360 * time.Second
}
return time.Duration(c.Timeouts.ResearchTimeout) * time.Second
}
func (c *Config) APITimeoutDuration() time.Duration {
return time.Duration(c.Timeouts.APITimeout) * time.Second
}
func (c *Config) TLSDialTimeoutDuration() time.Duration {
return time.Duration(c.Timeouts.TLSDialTimeout) * time.Second
}
func (c *Config) RefreshInterval() time.Duration {
return time.Duration(c.Refresh.IntervalMinutes) * time.Minute
}
func (c *Config) QuotaRecheckInterval() time.Duration {
return time.Duration(c.Refresh.QuotaRecheckMinutes) * time.Minute
}
// QuotaLiveCheckInterval returns the minimum interval between live per-request
// quota checks for the same account. Defaults to 5 seconds when unset.
func (c *Config) QuotaLiveCheckInterval() time.Duration {
if c == nil {
return 5 * time.Second
}
if c.Refresh.LiveCheckSeconds < 0 {
return 0
}
return time.Duration(c.Refresh.LiveCheckSeconds) * time.Second
}
// WebSearchEnabled returns the effective web search setting (default: true)
func (c *Config) WebSearchEnabled() bool {
if c.Proxy.EnableWebSearch == nil {
return true
}
return *c.Proxy.EnableWebSearch
}
// WorkspaceSearchEnabled returns the effective workspace search setting (default: false)
func (c *Config) WorkspaceSearchEnabled() bool {
if c.Proxy.EnableWorkspaceSearch == nil {
return false
}
return *c.Proxy.EnableWorkspaceSearch
}
// AskModeDefault returns the effective default ASK-mode setting (default: false).
// ASK mode maps to Notion's workflow config.useReadOnlyMode — when true, the
// model answers without making page edits. Per-request `-ask` suffix on the
// model name still overrides the default for that single call.
func (c *Config) AskModeDefault() bool {
if c == nil || c.Proxy.AskModeDefault == nil {
return false
}
return *c.Proxy.AskModeDefault
}
// NotionProxyURL returns the upstream proxy applied to all notion-bound
// traffic. Empty string means dial directly. Reads from AppConfig at call
// time so a /admin/settings PUT takes effect on the next dial without a
// process restart.
func (c *Config) NotionProxyURL() string {
if c == nil {
return ""
}
return strings.TrimSpace(c.Proxy.NotionProxy)
}
func boolPtr(b bool) *bool { return &b }