feat: create standalone native fnOS repository
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
GatewaySocket string
|
||||
GatewayPrefix string
|
||||
DataDir string
|
||||
SessionSecret string
|
||||
NginxBin string
|
||||
NginxPIDFile string
|
||||
NginxMimeTypes string
|
||||
NginxDefaultHTTPPort int
|
||||
NginxDefaultHTTPSPort int
|
||||
FrpcBin string
|
||||
FrpPIDFile string
|
||||
UpdaterSocket string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
dataDir := envOr("HAVLINE_DATA_DIR", defaultDataDir())
|
||||
if abs, err := filepath.Abs(dataDir); err == nil {
|
||||
dataDir = abs
|
||||
}
|
||||
return Config{
|
||||
ListenAddr: envOr("HAVLINE_LISTEN", ":6893"),
|
||||
GatewaySocket: envOr("HAVLINE_GATEWAY_SOCKET", ""),
|
||||
GatewayPrefix: envOr("HAVLINE_GATEWAY_PREFIX", ""),
|
||||
DataDir: dataDir,
|
||||
SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""),
|
||||
NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"),
|
||||
NginxPIDFile: envOr("HAVLINE_NGINX_PID", dataDir+"/nginx/nginx.pid"),
|
||||
NginxMimeTypes: envOr("HAVLINE_NGINX_MIME_TYPES", defaultMimeTypes()),
|
||||
NginxDefaultHTTPPort: envIntOr("HAVLINE_NGINX_HTTP_PORT", 80),
|
||||
NginxDefaultHTTPSPort: envIntOr("HAVLINE_NGINX_HTTPS_PORT", 443),
|
||||
FrpcBin: envOr("HAVLINE_FRPC_BIN", "frpc"),
|
||||
FrpPIDFile: envOr("HAVLINE_FRP_PID", dataDir+"/frp/frpc.pid"),
|
||||
UpdaterSocket: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) DefaultListenPort(httpsEnabled bool) int {
|
||||
if httpsEnabled {
|
||||
return c.NginxDefaultHTTPSPort
|
||||
}
|
||||
return c.NginxDefaultHTTPPort
|
||||
}
|
||||
|
||||
func (c Config) DBPath() string {
|
||||
return c.DataDir + "/havline.db"
|
||||
}
|
||||
|
||||
func (c Config) NginxDir() string {
|
||||
return c.DataDir + "/nginx"
|
||||
}
|
||||
|
||||
func (c Config) NginxConfigPath() string {
|
||||
return c.NginxDir() + "/nginx.conf"
|
||||
}
|
||||
|
||||
func (c Config) NginxCustomDir() string {
|
||||
return c.NginxDir() + "/custom"
|
||||
}
|
||||
|
||||
func (c Config) NginxGlobalCustomPath() string {
|
||||
return c.NginxCustomDir() + "/global.conf"
|
||||
}
|
||||
|
||||
func (c Config) NginxRulesDir() string {
|
||||
return c.NginxDir() + "/rules"
|
||||
}
|
||||
|
||||
func (c Config) NginxRuleCustomPath(ruleID int64) string {
|
||||
return filepath.Join(c.NginxRulesDir(), fmt.Sprintf("rule_%d.conf", ruleID))
|
||||
}
|
||||
|
||||
func (c Config) NginxBackupsDir() string {
|
||||
return c.NginxDir() + "/backups"
|
||||
}
|
||||
|
||||
func (c Config) NginxGlobalBackupsDir() string {
|
||||
return c.NginxBackupsDir() + "/global"
|
||||
}
|
||||
|
||||
func (c Config) NginxRuleBackupsDir(ruleID int64) string {
|
||||
return filepath.Join(c.NginxBackupsDir(), "rules", fmt.Sprintf("%d", ruleID))
|
||||
}
|
||||
|
||||
func (c Config) LogsDir() string {
|
||||
return c.DataDir + "/logs"
|
||||
}
|
||||
|
||||
func (c Config) CertsDir() string {
|
||||
return c.DataDir + "/certs"
|
||||
}
|
||||
|
||||
// SSHKnownHostsPath 是 SSH 主机密钥的 TOFU 记录文件(首次连接记录指纹,之后必须一致)。
|
||||
func (c Config) SSHKnownHostsPath() string {
|
||||
return filepath.Join(c.DataDir, "ssh_known_hosts")
|
||||
}
|
||||
|
||||
func (c Config) ErrorsDir() string {
|
||||
return c.NginxDir() + "/errors"
|
||||
}
|
||||
|
||||
func (c Config) ChinaCIDRPath() string {
|
||||
return c.NginxDir() + "/china_cidr.conf"
|
||||
}
|
||||
|
||||
func (c Config) ChinaCIDRTempPath() string {
|
||||
return c.NginxDir() + "/china_cidr.conf.tmp"
|
||||
}
|
||||
|
||||
func (c Config) FrpDir() string {
|
||||
return c.DataDir + "/frp"
|
||||
}
|
||||
|
||||
func (c Config) FrpConfigPath() string {
|
||||
return c.FrpDir() + "/frpc.toml"
|
||||
}
|
||||
|
||||
func (c Config) FrpLogPath() string {
|
||||
return filepath.Join(c.LogsDir(), "frpc.log")
|
||||
}
|
||||
|
||||
// LegacySessionSecret 是历史版本在未配置 HAVLINE_SESSION_SECRET 时使用的固定值。
|
||||
// 它同时是 secret box 的加密密钥,所以只提醒、不自动替换:
|
||||
// 换掉它会让库里已加密的凭据(frp token、通知密码等)再也解不开。
|
||||
const LegacySessionSecret = "change-me-in-production"
|
||||
|
||||
// sessionSecretPath 会话密钥的持久化位置(与数据库同在数据目录,重建容器/升级都不受影响)
|
||||
func (c Config) sessionSecretPath() string {
|
||||
return filepath.Join(c.DataDir, "session_secret")
|
||||
}
|
||||
|
||||
// ResolveSessionSecret 补齐会话密钥并返回它的来源(写进启动日志,便于确认到底用的哪一个):
|
||||
// - 显式配置了(包括历史默认值)→ 原样使用,与既有数据保持兼容;
|
||||
// - 没配置 → 读数据目录里的 session_secret;不存在或内容明显无效就生成一个(0600)。
|
||||
//
|
||||
// 这样 Docker 与原生部署都不必手工准备密钥,也不会出现「忘配就用固定弱密钥跑在公网」。
|
||||
func (c *Config) ResolveSessionSecret() (string, error) {
|
||||
if secret := strings.TrimSpace(c.SessionSecret); secret != "" {
|
||||
if secret == LegacySessionSecret {
|
||||
return "环境变量(仍是历史默认值,建议改成随机值,注意更换后需重新登录)", nil
|
||||
}
|
||||
return "环境变量", nil
|
||||
}
|
||||
path := c.sessionSecretPath()
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
// 长度不足或带空白的视为损坏(人工改坏、写了一半):重新生成,而不是带着坏密钥启动
|
||||
if secret := strings.TrimSpace(string(data)); len(secret) >= 32 && !strings.ContainsAny(secret, " \t\r\n") {
|
||||
c.SessionSecret = secret
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
secret, err := randomSessionSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(c.DataDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 先写临时文件再改名:避免中途失败留下半个密钥
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(secret+"\n"), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
// WriteFile 的权限会被 umask 干扰,这里再显式收一道(密钥文件不该让同机其他用户读到)
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.SessionSecret = secret
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// randomSessionSecret 32 字节随机数的十六进制表示(64 字符)
|
||||
func randomSessionSecret() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("生成会话密钥失败: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func defaultDataDir() string {
|
||||
if _, err := os.Stat("/data"); err == nil {
|
||||
return "/data"
|
||||
}
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
return filepath.Join(cwd, ".data")
|
||||
}
|
||||
return "/data"
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func defaultMimeTypes() string {
|
||||
candidates := []string{
|
||||
"/etc/nginx/mime.types",
|
||||
"deploy/nginx/mime.types",
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return "/etc/nginx/mime.types"
|
||||
}
|
||||
|
||||
func envIntOr(key string, fallback int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadNginxDefaultPorts(t *testing.T) {
|
||||
t.Setenv("HAVLINE_NGINX_HTTP_PORT", "18080")
|
||||
t.Setenv("HAVLINE_NGINX_HTTPS_PORT", "9443")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.NginxDefaultHTTPPort != 18080 {
|
||||
t.Fatalf("http port %d", cfg.NginxDefaultHTTPPort)
|
||||
}
|
||||
if cfg.NginxDefaultHTTPSPort != 9443 {
|
||||
t.Fatalf("https port %d", cfg.NginxDefaultHTTPSPort)
|
||||
}
|
||||
if cfg.DefaultListenPort(false) != 18080 {
|
||||
t.Fatalf("default http listen %d", cfg.DefaultListenPort(false))
|
||||
}
|
||||
if cfg.DefaultListenPort(true) != 9443 {
|
||||
t.Fatalf("default https listen %d", cfg.DefaultListenPort(true))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNginxDefaultPortsFallback(t *testing.T) {
|
||||
os.Unsetenv("HAVLINE_NGINX_HTTP_PORT")
|
||||
os.Unsetenv("HAVLINE_NGINX_HTTPS_PORT")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.NginxDefaultHTTPPort != 80 || cfg.NginxDefaultHTTPSPort != 443 {
|
||||
t.Fatalf("ports %d %d", cfg.NginxDefaultHTTPPort, cfg.NginxDefaultHTTPSPort)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 未配置时:生成一个 64 字符密钥、以 0600 落盘,并且下次启动复用同一个
|
||||
func TestResolveSessionSecretGeneratesAndReuses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{DataDir: dir}
|
||||
|
||||
source, err := cfg.ResolveSessionSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("生成密钥失败:%v", err)
|
||||
}
|
||||
if len(cfg.SessionSecret) != 64 {
|
||||
t.Fatalf("应生成 64 字符密钥,实际 %q", cfg.SessionSecret)
|
||||
}
|
||||
if source != cfg.sessionSecretPath() {
|
||||
t.Fatalf("来源应指向密钥文件,实际 %q", source)
|
||||
}
|
||||
info, err := os.Stat(cfg.sessionSecretPath())
|
||||
if err != nil {
|
||||
t.Fatalf("密钥文件没写出来:%v", err)
|
||||
}
|
||||
// Windows 没有 POSIX 权限位(Perm() 返回合成值),只在类 Unix 上校验 0600
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("密钥文件权限应为 0600,实际 %v", info.Mode().Perm())
|
||||
}
|
||||
|
||||
// 模拟下一次启动:必须复用同一个密钥,否则所有人都会掉线
|
||||
again := Config{DataDir: dir}
|
||||
if _, err := again.ResolveSessionSecret(); err != nil {
|
||||
t.Fatalf("二次读取失败:%v", err)
|
||||
}
|
||||
if again.SessionSecret != cfg.SessionSecret {
|
||||
t.Fatalf("重复启动应复用同一个密钥:%q vs %q", again.SessionSecret, cfg.SessionSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式配置优先级最高,且不应该再写密钥文件
|
||||
func TestResolveSessionSecretPrefersExplicitValue(t *testing.T) {
|
||||
cfg := Config{DataDir: t.TempDir(), SessionSecret: "explicit-value-1234567890"}
|
||||
source, err := cfg.ResolveSessionSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("不应报错:%v", err)
|
||||
}
|
||||
if cfg.SessionSecret != "explicit-value-1234567890" {
|
||||
t.Fatalf("显式配置应原样保留,实际 %q", cfg.SessionSecret)
|
||||
}
|
||||
if source != "环境变量" {
|
||||
t.Fatalf("来源应标为环境变量,实际 %q", source)
|
||||
}
|
||||
if _, err := os.Stat(cfg.sessionSecretPath()); err == nil {
|
||||
t.Fatal("显式配置时不应生成密钥文件")
|
||||
}
|
||||
}
|
||||
|
||||
// 历史默认值必须原样保留(它同时是 secret box 的密钥),但来源要提示出来
|
||||
func TestResolveSessionSecretKeepsLegacyValueWithWarning(t *testing.T) {
|
||||
cfg := Config{DataDir: t.TempDir(), SessionSecret: LegacySessionSecret}
|
||||
source, err := cfg.ResolveSessionSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("不应报错:%v", err)
|
||||
}
|
||||
if cfg.SessionSecret != LegacySessionSecret {
|
||||
t.Fatalf("历史默认值必须保留,实际 %q", cfg.SessionSecret)
|
||||
}
|
||||
if source == "" || source == "环境变量" {
|
||||
t.Fatalf("应提示这仍是历史默认值,实际 %q", source)
|
||||
}
|
||||
}
|
||||
|
||||
// 密钥文件被写坏时重新生成,而不是带着坏密钥启动
|
||||
func TestResolveSessionSecretReplacesCorruptedFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "session_secret"), []byte("short\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := Config{DataDir: dir}
|
||||
if _, err := cfg.ResolveSessionSecret(); err != nil {
|
||||
t.Fatalf("不应报错:%v", err)
|
||||
}
|
||||
if len(cfg.SessionSecret) != 64 {
|
||||
t.Fatalf("损坏的密钥文件应被替换,实际 %q", cfg.SessionSecret)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user