e583f5b538
Add remote config fetching capability to the -C CLI flag, enabling centralized fleet management of gost nodes. - readConfig() now detects http:// and https:// URLs and fetches them via HTTP GET with a 30s timeout and shared client pool - Format auto-detection from Content-Type header (using mime parsing) with fallback to URL path extension (.yaml/.json) - 10 MiB response size limit to prevent memory exhaustion - URL userinfo stripped from error messages for safety - Fix ReadFile to force viper config type from file extension, preventing format detection ambiguity - Fix viper config search path fallback: skip cfg.Load() when -C flags are explicitly provided, avoiding unwanted merges - Tests: URL fetch (YAML/JSON), non-200, oversize, detectFormat, isHTTPURL, sanitizeURL, and combined file+URL Parse scenario
481 lines
14 KiB
Go
481 lines
14 KiB
Go
package parser
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/go-gost/x/config"
|
|
)
|
|
|
|
func TestMergeConfig_NilBoth(t *testing.T) {
|
|
got := mergeConfig(nil, nil)
|
|
if got != nil {
|
|
t.Fatal("expected nil for two nil inputs")
|
|
}
|
|
}
|
|
|
|
func TestMergeConfig_NilFirst(t *testing.T) {
|
|
cfg2 := &config.Config{
|
|
Services: []*config.ServiceConfig{{Name: "svc"}},
|
|
}
|
|
got := mergeConfig(nil, cfg2)
|
|
if len(got.Services) != 1 || got.Services[0].Name != "svc" {
|
|
t.Fatal("expected cfg2 to be returned when cfg1 is nil")
|
|
}
|
|
}
|
|
|
|
func TestMergeConfig_NilSecond(t *testing.T) {
|
|
cfg1 := &config.Config{
|
|
Services: []*config.ServiceConfig{{Name: "svc"}},
|
|
}
|
|
got := mergeConfig(cfg1, nil)
|
|
if len(got.Services) != 1 || got.Services[0].Name != "svc" {
|
|
t.Fatal("expected cfg1 to be returned when cfg2 is nil")
|
|
}
|
|
}
|
|
|
|
func TestMergeConfig_AppendsAllSlices(t *testing.T) {
|
|
cfg1 := &config.Config{
|
|
Services: []*config.ServiceConfig{{Name: "s1"}},
|
|
Chains: []*config.ChainConfig{{Name: "c1"}},
|
|
Hops: []*config.HopConfig{{Name: "h1"}},
|
|
Authers: []*config.AutherConfig{{Name: "a1"}},
|
|
Admissions: []*config.AdmissionConfig{{Name: "ad1"}},
|
|
Bypasses: []*config.BypassConfig{{Name: "b1"}},
|
|
Resolvers: []*config.ResolverConfig{{Name: "r1"}},
|
|
Hosts: []*config.HostsConfig{{Name: "hosts1"}},
|
|
Ingresses: []*config.IngressConfig{{Name: "i1"}},
|
|
SDs: []*config.SDConfig{{Name: "sd1"}},
|
|
Recorders: []*config.RecorderConfig{{Name: "rec1"}},
|
|
Limiters: []*config.LimiterConfig{{Name: "l1"}},
|
|
Quotas: []*config.QuotaConfig{{Name: "q1"}},
|
|
CLimiters: []*config.LimiterConfig{{Name: "cl1"}},
|
|
RLimiters: []*config.LimiterConfig{{Name: "rl1"}},
|
|
Loggers: []*config.LoggerConfig{{Name: "log1"}},
|
|
Routers: []*config.RouterConfig{{Name: "rt1"}},
|
|
Observers: []*config.ObserverConfig{{Name: "obs1"}},
|
|
}
|
|
cfg2 := &config.Config{
|
|
Services: []*config.ServiceConfig{{Name: "s2"}},
|
|
Chains: []*config.ChainConfig{{Name: "c2"}},
|
|
Hops: []*config.HopConfig{{Name: "h2"}},
|
|
Authers: []*config.AutherConfig{{Name: "a2"}},
|
|
Admissions: []*config.AdmissionConfig{{Name: "ad2"}},
|
|
Bypasses: []*config.BypassConfig{{Name: "b2"}},
|
|
Resolvers: []*config.ResolverConfig{{Name: "r2"}},
|
|
Hosts: []*config.HostsConfig{{Name: "hosts2"}},
|
|
Ingresses: []*config.IngressConfig{{Name: "i2"}},
|
|
SDs: []*config.SDConfig{{Name: "sd2"}},
|
|
Recorders: []*config.RecorderConfig{{Name: "rec2"}},
|
|
Limiters: []*config.LimiterConfig{{Name: "l2"}},
|
|
Quotas: []*config.QuotaConfig{{Name: "q2"}},
|
|
CLimiters: []*config.LimiterConfig{{Name: "cl2"}},
|
|
RLimiters: []*config.LimiterConfig{{Name: "rl2"}},
|
|
Loggers: []*config.LoggerConfig{{Name: "log2"}},
|
|
Routers: []*config.RouterConfig{{Name: "rt2"}},
|
|
Observers: []*config.ObserverConfig{{Name: "obs2"}},
|
|
}
|
|
|
|
got := mergeConfig(cfg1, cfg2)
|
|
|
|
if len(got.Services) != 2 || got.Services[0].Name != "s1" || got.Services[1].Name != "s2" {
|
|
t.Fatal("services not properly appended")
|
|
}
|
|
if len(got.Chains) != 2 {
|
|
t.Fatal("chains not properly appended")
|
|
}
|
|
if len(got.Hops) != 2 {
|
|
t.Fatal("hops not properly appended")
|
|
}
|
|
if len(got.Authers) != 2 {
|
|
t.Fatal("authers not properly appended")
|
|
}
|
|
if len(got.Admissions) != 2 {
|
|
t.Fatal("admissions not properly appended")
|
|
}
|
|
if len(got.Bypasses) != 2 {
|
|
t.Fatal("bypasses not properly appended")
|
|
}
|
|
if len(got.Resolvers) != 2 {
|
|
t.Fatal("resolvers not properly appended")
|
|
}
|
|
if len(got.Hosts) != 2 {
|
|
t.Fatal("hosts not properly appended")
|
|
}
|
|
if len(got.Ingresses) != 2 {
|
|
t.Fatal("ingresses not properly appended")
|
|
}
|
|
if len(got.SDs) != 2 {
|
|
t.Fatal("SDs not properly appended")
|
|
}
|
|
if len(got.Recorders) != 2 {
|
|
t.Fatal("recorders not properly appended")
|
|
}
|
|
if len(got.Limiters) != 2 {
|
|
t.Fatal("limiters not properly appended")
|
|
}
|
|
if len(got.Quotas) != 2 {
|
|
t.Fatal("quotas not properly appended")
|
|
}
|
|
if len(got.CLimiters) != 2 {
|
|
t.Fatal("climiters not properly appended")
|
|
}
|
|
if len(got.RLimiters) != 2 {
|
|
t.Fatal("rlimiters not properly appended")
|
|
}
|
|
if len(got.Loggers) != 2 {
|
|
t.Fatal("loggers not properly appended")
|
|
}
|
|
if len(got.Routers) != 2 {
|
|
t.Fatal("routers not properly appended")
|
|
}
|
|
if len(got.Observers) != 2 {
|
|
t.Fatal("observers not properly appended")
|
|
}
|
|
}
|
|
|
|
func TestMergeConfig_ScalarOverrides(t *testing.T) {
|
|
cfg1 := &config.Config{
|
|
TLS: &config.TLSConfig{ServerName: "old"},
|
|
Log: &config.LogConfig{Level: "info"},
|
|
API: &config.APIConfig{Addr: ":8080"},
|
|
Metrics: &config.MetricsConfig{Addr: ":9090"},
|
|
Profiling: &config.ProfilingConfig{Addr: ":6060"},
|
|
}
|
|
cfg2 := &config.Config{
|
|
TLS: &config.TLSConfig{ServerName: "new"},
|
|
Log: &config.LogConfig{Level: "debug"},
|
|
API: &config.APIConfig{Addr: ":8081"},
|
|
Metrics: &config.MetricsConfig{Addr: ":9091"},
|
|
Profiling: &config.ProfilingConfig{Addr: ":6061"},
|
|
}
|
|
|
|
got := mergeConfig(cfg1, cfg2)
|
|
|
|
if got.TLS.ServerName != "new" {
|
|
t.Fatalf("TLS not overridden: got %q, want %q", got.TLS.ServerName, "new")
|
|
}
|
|
if got.Log.Level != "debug" {
|
|
t.Fatalf("Log not overridden: got %q, want %q", got.Log.Level, "debug")
|
|
}
|
|
if got.API.Addr != ":8081" {
|
|
t.Fatalf("API not overridden: got %q, want %q", got.API.Addr, ":8081")
|
|
}
|
|
if got.Metrics.Addr != ":9091" {
|
|
t.Fatalf("Metrics not overridden: got %q, want %q", got.Metrics.Addr, ":9091")
|
|
}
|
|
if got.Profiling.Addr != ":6061" {
|
|
t.Fatalf("Profiling not overridden: got %q, want %q", got.Profiling.Addr, ":6061")
|
|
}
|
|
}
|
|
|
|
func TestMergeConfig_ScalarKeepsCfg1WhenCfg2Nil(t *testing.T) {
|
|
cfg1 := &config.Config{
|
|
TLS: &config.TLSConfig{ServerName: "keep"},
|
|
Log: &config.LogConfig{Level: "warn"},
|
|
API: &config.APIConfig{Addr: ":8080"},
|
|
Metrics: &config.MetricsConfig{Addr: ":9090"},
|
|
Profiling: &config.ProfilingConfig{Addr: ":6060"},
|
|
}
|
|
cfg2 := &config.Config{} // all scalars nil
|
|
|
|
got := mergeConfig(cfg1, cfg2)
|
|
|
|
if got.TLS.ServerName != "keep" {
|
|
t.Fatal("TLS should be kept from cfg1")
|
|
}
|
|
if got.Log.Level != "warn" {
|
|
t.Fatal("Log should be kept from cfg1")
|
|
}
|
|
if got.API.Addr != ":8080" {
|
|
t.Fatal("API should be kept from cfg1")
|
|
}
|
|
if got.Metrics.Addr != ":9090" {
|
|
t.Fatal("Metrics should be kept from cfg1")
|
|
}
|
|
if got.Profiling.Addr != ":6060" {
|
|
t.Fatal("Profiling should be kept from cfg1")
|
|
}
|
|
}
|
|
|
|
func TestInit(t *testing.T) {
|
|
Init(Args{
|
|
CfgFiles: []string{"test.yml"},
|
|
Services: []string{"s1"},
|
|
Nodes: []string{"n1"},
|
|
Debug: true,
|
|
Trace: false,
|
|
ApiAddr: ":8080",
|
|
MetricsAddr: ":9090",
|
|
})
|
|
|
|
if len(defaultParser.args.CfgFiles) != 1 || defaultParser.args.CfgFiles[0] != "test.yml" {
|
|
t.Fatalf("CfgFiles not set: got %v", defaultParser.args.CfgFiles)
|
|
}
|
|
if len(defaultParser.args.Services) != 1 || defaultParser.args.Services[0] != "s1" {
|
|
t.Fatal("Services not set")
|
|
}
|
|
if len(defaultParser.args.Nodes) != 1 || defaultParser.args.Nodes[0] != "n1" {
|
|
t.Fatal("Nodes not set")
|
|
}
|
|
if !defaultParser.args.Debug {
|
|
t.Fatal("Debug not set")
|
|
}
|
|
if defaultParser.args.Trace {
|
|
t.Fatal("Trace should be false")
|
|
}
|
|
if defaultParser.args.ApiAddr != ":8080" {
|
|
t.Fatal("ApiAddr not set")
|
|
}
|
|
if defaultParser.args.MetricsAddr != ":9090" {
|
|
t.Fatal("MetricsAddr not set")
|
|
}
|
|
}
|
|
|
|
func TestParse_MultiFile(t *testing.T) {
|
|
// Create two temporary config files.
|
|
cfg1 := &config.Config{
|
|
Services: []*config.ServiceConfig{{Name: "s1", Addr: ":8080"}},
|
|
}
|
|
cfg2 := &config.Config{
|
|
Services: []*config.ServiceConfig{{Name: "s2", Addr: ":8081"}},
|
|
Log: &config.LogConfig{Level: "debug"},
|
|
Quotas: []*config.QuotaConfig{{Name: "q1"}},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
file1 := filepath.Join(tmpDir, "cfg1.yml")
|
|
file2 := filepath.Join(tmpDir, "cfg2.yml")
|
|
|
|
for _, entry := range []struct {
|
|
path string
|
|
cfg *config.Config
|
|
}{
|
|
{file1, cfg1},
|
|
{file2, cfg2},
|
|
} {
|
|
f, err := os.Create(entry.path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := entry.cfg.Write(f, "yaml"); err != nil {
|
|
f.Close()
|
|
t.Fatal(err)
|
|
}
|
|
f.Close()
|
|
}
|
|
|
|
Init(Args{
|
|
CfgFiles: []string{file1, file2},
|
|
})
|
|
|
|
got, err := Parse()
|
|
if err != nil {
|
|
t.Fatalf("Parse(): %v", err)
|
|
}
|
|
|
|
if len(got.Services) != 2 {
|
|
t.Fatalf("expected 2 services, got %d", len(got.Services))
|
|
}
|
|
if got.Log == nil || got.Log.Level != "debug" {
|
|
t.Fatal("Log.Level not set from cfg2")
|
|
}
|
|
if len(got.Quotas) != 1 || got.Quotas[0].Name != "q1" {
|
|
t.Fatal("Quotas not merged from cfg2")
|
|
}
|
|
}
|
|
|
|
func TestIsHTTPURL(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected bool
|
|
}{
|
|
{"http://example.com/config.yaml", true},
|
|
{"https://example.com/config.json", true},
|
|
{"ftp://example.com/config.yaml", false},
|
|
{"config.yaml", false},
|
|
{"/path/to/config.yml", false},
|
|
{"http://[::1]:8080/cfg", true},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := isHTTPURL(tt.input); got != tt.expected {
|
|
t.Errorf("isHTTPURL(%q) = %v, want %v", tt.input, got, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectFormat(t *testing.T) {
|
|
tests := []struct {
|
|
contentType string
|
|
url string
|
|
want string
|
|
}{
|
|
{"", "http://example.com/config.json", "json"},
|
|
{"", "http://example.com/config.yml", "yaml"},
|
|
{"", "http://example.com/config.yaml", "yaml"},
|
|
{"", "http://example.com/config", "yaml"}, // default
|
|
{"application/json", "http://example.com/config", "json"},
|
|
{"application/yaml", "http://example.com/config", "yaml"},
|
|
{"application/x-yaml", "http://example.com/config", "yaml"},
|
|
{"text/yaml", "http://example.com/config", "yaml"},
|
|
{"text/x-yaml", "http://example.com/config", "yaml"},
|
|
{"text/plain; charset=utf-8", "http://example.com/config.json", "json"}, // content-type ignored, extension wins
|
|
{"text/plain", "http://example.com/config", "yaml"}, // unknown -> default
|
|
}
|
|
for _, tt := range tests {
|
|
if got := detectFormat(tt.contentType, tt.url); got != tt.want {
|
|
t.Errorf("detectFormat(%q, %q) = %q, want %q", tt.contentType, tt.url, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSanitizeURL(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"http://user:pass@example.com/path", "http://example.com/path"},
|
|
{"https://example.com/path", "https://example.com/path"},
|
|
{"not a url", "not%20a%20url"},
|
|
{"http://user@example.com/path", "http://example.com/path"}, // user with no password
|
|
}
|
|
for _, tt := range tests {
|
|
if got := sanitizeURL(tt.input); got != tt.expected {
|
|
t.Errorf("sanitizeURL(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadConfigFromURL_YAML(t *testing.T) {
|
|
// Write a YAML config and serve it via httptest.
|
|
cfg := &config.Config{
|
|
Services: []*config.ServiceConfig{{Name: "svc", Addr: ":8080"}},
|
|
Log: &config.LogConfig{Level: "debug"},
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := cfg.Write(&buf, "yaml"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := buf.Bytes()
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/yaml")
|
|
w.Write(body)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
got := &config.Config{}
|
|
if err := readConfigFromURL(ts.URL, got); err != nil {
|
|
t.Fatalf("readConfigFromURL: %v", err)
|
|
}
|
|
if len(got.Services) != 1 || got.Services[0].Name != "svc" {
|
|
t.Fatalf("unexpected services: %+v", got.Services)
|
|
}
|
|
if got.Log == nil || got.Log.Level != "debug" {
|
|
t.Fatalf("unexpected log: %+v", got.Log)
|
|
}
|
|
}
|
|
|
|
func TestReadConfigFromURL_JSON(t *testing.T) {
|
|
// Remote serving JSON with explicit Content-Type.
|
|
cfg := &config.Config{
|
|
Services: []*config.ServiceConfig{{Name: "json-svc", Addr: ":9090"}},
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := cfg.Write(&buf, "json"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := buf.Bytes()
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write(body)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
got := &config.Config{}
|
|
if err := readConfigFromURL(ts.URL, got); err != nil {
|
|
t.Fatalf("readConfigFromURL: %v", err)
|
|
}
|
|
if got.Services[0].Name != "json-svc" {
|
|
t.Fatalf("unexpected service name: %s", got.Services[0].Name)
|
|
}
|
|
}
|
|
|
|
func TestReadConfigFromURL_Non200(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
err := readConfigFromURL(ts.URL, &config.Config{})
|
|
if err == nil {
|
|
t.Fatal("expected error for 404 response")
|
|
}
|
|
}
|
|
|
|
func TestReadConfigFromURL_OverSize(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/yaml")
|
|
w.Write(make([]byte, maxConfigSize+1))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
err := readConfigFromURL(ts.URL, &config.Config{})
|
|
if err == nil {
|
|
t.Fatal("expected error for oversized response")
|
|
}
|
|
}
|
|
|
|
func TestParse_FileAndURL(t *testing.T) {
|
|
// Create a local file.
|
|
fileCfg := &config.Config{
|
|
Services: []*config.ServiceConfig{{Name: "file-svc", Addr: ":8080"}},
|
|
}
|
|
tmpDir := t.TempDir()
|
|
f, err := os.Create(filepath.Join(tmpDir, "local.yml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := fileCfg.Write(f, "yaml"); err != nil {
|
|
f.Close()
|
|
t.Fatal(err)
|
|
}
|
|
f.Close()
|
|
|
|
// Serve a second config over HTTP.
|
|
urlCfg := &config.Config{
|
|
Services: []*config.ServiceConfig{{Name: "url-svc", Addr: ":8081"}},
|
|
Log: &config.LogConfig{Level: "debug"},
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := urlCfg.Write(&buf, "yaml"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := buf.Bytes()
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/yaml")
|
|
w.Write(body)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
Init(Args{
|
|
CfgFiles: []string{filepath.Join(tmpDir, "local.yml"), ts.URL},
|
|
})
|
|
|
|
got, err := Parse()
|
|
if err != nil {
|
|
t.Fatalf("Parse(): %v", err)
|
|
}
|
|
if len(got.Services) != 2 {
|
|
t.Fatalf("expected 2 services, got %d", len(got.Services))
|
|
}
|
|
if got.Log == nil || got.Log.Level != "debug" {
|
|
t.Fatal("Log.Level not set from URL config")
|
|
}
|
|
}
|