Files
FlClash/core/config_test.go
T
chen08209 2fa2f7f816 feat(core): bind the Android Core with gomobile
Changelog: Share link import and mrs rule sets
2026-10-11 12:28:52 +08:00

1023 lines
30 KiB
Go

package core
import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/metacubex/mihomo/adapter/inbound"
"github.com/metacubex/mihomo/component/updater"
"github.com/metacubex/mihomo/config"
"github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/hub/route"
authStore "github.com/metacubex/mihomo/listener/auth"
"github.com/metacubex/mihomo/log"
"github.com/metacubex/mihomo/tunnel"
)
func withCurrentConfig(t *testing.T, cfg *config.Config) {
t.Helper()
previous := currentConfig
currentConfig = cfg
t.Cleanup(func() { currentConfig = previous })
}
func TestRouteConfigCarriesControllerCredentials(t *testing.T) {
cfg := &config.Config{
General: &config.General{LogLevel: log.DEBUG},
Controller: &config.Controller{
ExternalController: "127.0.0.1:9090",
ExternalControllerTLS: "127.0.0.1:9443",
ExternalControllerUnix: "/tmp/flclash.sock",
ExternalControllerPipe: `\\.\pipe\flclash`,
ExternalControllerRoutingMark: 1234,
ExternalDohServer: "/dns-query",
Secret: "s3cret",
Cors: config.Cors{
AllowOrigins: []string{"https://example.test"},
AllowPrivateNetwork: true,
},
},
TLS: &config.TLS{
Certificate: "cert",
PrivateKey: "key",
ClientAuthType: "require",
ClientAuthCert: "clientCert",
EchKey: "ech",
},
}
got := routeConfig(cfg)
if got.Secret != "s3cret" {
t.Errorf("Secret = %q, want it preserved; an empty secret unauthenticates the controller", got.Secret)
}
if got.Addr != "127.0.0.1:9090" || got.TLSAddr != "127.0.0.1:9443" {
t.Errorf("addresses = %q/%q, want the controller's own listen addresses", got.Addr, got.TLSAddr)
}
if got.UnixAddr != "/tmp/flclash.sock" || got.PipeAddr != `\\.\pipe\flclash` {
t.Errorf("local addresses = %q/%q, want them preserved", got.UnixAddr, got.PipeAddr)
}
if got.RoutingMark != 1234 || got.DohServer != "/dns-query" {
t.Errorf("routingMark/dohServer = %d/%q, want 1234//dns-query", got.RoutingMark, got.DohServer)
}
if got.Certificate != "cert" || got.PrivateKey != "key" ||
got.ClientAuthType != "require" || got.ClientAuthCert != "clientCert" || got.EchKey != "ech" {
t.Errorf("TLS material = %+v, want it carried over from cfg.TLS", got)
}
if len(got.Cors.AllowOrigins) != 1 || got.Cors.AllowOrigins[0] != "https://example.test" ||
!got.Cors.AllowPrivateNetwork {
t.Errorf("Cors = %+v, want the configured origins", got.Cors)
}
if !got.IsDebug {
t.Error("IsDebug = false, want it derived from a DEBUG log level")
}
}
func TestRouteConfigToleratesMissingTLSSection(t *testing.T) {
cfg := &config.Config{
General: &config.General{},
Controller: &config.Controller{ExternalController: "127.0.0.1:9090"},
}
got := routeConfig(cfg)
if got.Certificate != "" || got.PrivateKey != "" {
t.Errorf("TLS material = %q/%q, want empty when cfg.TLS is absent", got.Certificate, got.PrivateKey)
}
}
func TestUpdateConfigRejectsAnUnappliedConfig(t *testing.T) {
withCurrentConfig(t, nil)
if err := updateConfig(&UpdateParams{}); err != errConfigNotApplied {
t.Errorf("updateConfig error = %v, want errConfigNotApplied", err)
}
}
// The host reads updateConfig's result as a message, so the failure has to
// arrive as one rather than as the envelope's error.
func TestUpdateConfigMethodReportsAnUnappliedConfigAsAMessage(t *testing.T) {
withCurrentConfig(t, nil)
frame := handleMethodCall(&MethodCall{
ID: "1",
Method: updateConfigMethod,
Arguments: json.RawMessage(`{}`),
})
var envelope struct {
Result string `json:"result"`
Error *MethodError `json:"error"`
}
if err := json.Unmarshal(frame, &envelope); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if envelope.Error != nil || envelope.Result != errConfigNotApplied.Error() {
t.Errorf("envelope = %+v, want the message %q as the result", envelope, errConfigNotApplied)
}
}
func TestUpdateConfigAppliesAllowLan(t *testing.T) {
withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}})
allowLan := true
if err := updateConfig(&UpdateParams{AllowLan: &allowLan}); err != nil {
t.Fatalf("updateConfig error: %v", err)
}
if !currentConfig.General.AllowLan {
t.Error("AllowLan stayed false; the patched value never reached the general config")
}
}
func TestUpdateConfigAppliesGeoResourceUrls(t *testing.T) {
withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}})
requested := map[string]string{
"mmdb": "https://example.test/geoip.metadb",
"asn": "https://example.test/GeoLite2-ASN.mmdb",
"geoip": "https://example.test/geoip.dat",
"geosite": "https://example.test/geosite.dat",
}
for _, resource := range geoResources {
resource, previous := resource, resource.url()
t.Cleanup(func() { resource.setUrl(previous) })
}
if err := updateConfig(&UpdateParams{GeoXUrl: requested}); err != nil {
t.Fatalf("updateConfig error: %v", err)
}
for key, want := range requested {
if got := geoResources[strings.ToUpper(key)].url(); got != want {
t.Errorf("%s url = %q, want %q; sync and the auto updater would keep the old link", key, got, want)
}
}
}
func TestSetGeoResourceUrlWritesOnlyAChangedLink(t *testing.T) {
const current = "https://example.test/geosite.dat"
var writes []string
previous := geoResources
geoResources = map[string]geoResource{
"GEOSITE": {
url: func() string { return current },
setUrl: func(link string) { writes = append(writes, link) },
},
}
t.Cleanup(func() { geoResources = previous })
setGeoResourceUrl("geosite", current)
setGeoResourceUrl("geosite", "")
setGeoResourceUrl("nope", "https://example.test/nope.dat")
if len(writes) != 0 {
t.Fatalf("writes = %q, want none for an unchanged, empty or unknown link", writes)
}
setGeoResourceUrl("geosite", "https://example.test/next.dat")
if len(writes) != 1 || writes[0] != "https://example.test/next.dat" {
t.Errorf("writes = %q, want the changed link once", writes)
}
}
func TestUpdateConfigAppliesAuthenticationAndClearsLoopbackExemptions(t *testing.T) {
withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}})
currentConfig.General.SkipAuthPrefixes = []netip.Prefix{
netip.MustParsePrefix("127.0.0.1/32"),
}
inbound.SetSkipAuthPrefixes(currentConfig.General.SkipAuthPrefixes)
t.Cleanup(func() {
authStore.Default.SetAuthenticator(nil)
inbound.SetSkipAuthPrefixes(nil)
})
users := []string{"user:pass"}
if err := updateConfig(&UpdateParams{Authentication: &users}); err != nil {
t.Fatalf("updateConfig error: %v", err)
}
authenticator := authStore.Default.Authenticator()
if authenticator == nil || !authenticator.Verify("user", "pass") {
t.Error("authenticator missing or rejecting the configured credentials")
}
if inbound.SkipAuthRemoteAddress("127.0.0.1:1234") {
t.Error("loopback stayed exempt; local apps could bypass the credentials")
}
empty := []string{}
if err := updateConfig(&UpdateParams{Authentication: &empty}); err != nil {
t.Fatalf("updateConfig error: %v", err)
}
if authStore.Default.Authenticator() != nil {
t.Error("authenticator survived an empty authentication list")
}
}
func TestUpdateConfigPatchesOnlyTheTunFieldsItWasGiven(t *testing.T) {
withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}})
currentConfig.General.Tun.Device = "keep-me"
device := "flclash-tun"
if err := updateConfig(&UpdateParams{Tun: &tunSchema{Enable: true}}); err != nil {
t.Fatalf("updateConfig error: %v", err)
}
if !currentConfig.General.Tun.Enable {
t.Error("Tun.Enable stayed false")
}
if currentConfig.General.Tun.Device != "keep-me" {
t.Errorf("Tun.Device = %q, want an omitted field left untouched", currentConfig.General.Tun.Device)
}
if err := updateConfig(&UpdateParams{Tun: &tunSchema{Enable: true, Device: &device}}); err != nil {
t.Fatalf("updateConfig error: %v", err)
}
if currentConfig.General.Tun.Device != device {
t.Errorf("Tun.Device = %q, want %q", currentConfig.General.Tun.Device, device)
}
}
func TestUpdateConfigDecodesTheTunSettingsTheHostSends(t *testing.T) {
withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}})
var params UpdateParams
payload := `{"tun":{"enable":true,"mtu":1400,"congestion-controller":"bbr","strict-route":true,
"route-exclude-address":["10.0.0.0/8"]}}`
if err := json.Unmarshal([]byte(payload), &params); err != nil {
t.Fatalf("decode: %v", err)
}
if err := updateConfig(&params); err != nil {
t.Fatalf("updateConfig error: %v", err)
}
tun := currentConfig.General.Tun
if tun.MTU != 1400 || tun.CongestionController != "bbr" || !tun.StrictRoute {
t.Errorf("MTU = %d, CongestionController = %q, StrictRoute = %v", tun.MTU, tun.CongestionController, tun.StrictRoute)
}
if len(tun.RouteExcludeAddress) != 1 || tun.RouteExcludeAddress[0] != netip.MustParsePrefix("10.0.0.0/8") {
t.Errorf("RouteExcludeAddress = %v", tun.RouteExcludeAddress)
}
}
func TestUpdateConfigLeavesTheControllerAloneWhenTheAddressIsUnchanged(t *testing.T) {
withCurrentConfig(t, &config.Config{
General: &config.General{},
Controller: &config.Controller{ExternalController: "127.0.0.1:9090", Secret: "s3cret"},
})
address := "127.0.0.1:9090"
if err := updateConfig(&UpdateParams{ExternalController: &address}); err != nil {
t.Fatalf("updateConfig error: %v", err)
}
if currentConfig.Controller.Secret != "s3cret" {
t.Errorf("Secret = %q, want the controller left untouched", currentConfig.Controller.Secret)
}
}
func TestUpdateConfigReplacesTheSecretTheProfileCarried(t *testing.T) {
withCurrentConfig(t, &config.Config{
General: &config.General{},
Controller: &config.Controller{Secret: "from-the-profile"},
})
closed, secret := "", "generated"
if err := updateConfig(&UpdateParams{ExternalController: &closed, Secret: &secret}); err != nil {
t.Fatalf("updateConfig error: %v", err)
}
if currentConfig.Controller.Secret != secret {
t.Errorf("Secret = %q, want %q", currentConfig.Controller.Secret, secret)
}
}
func TestUpdateConfigServesTheControllerBehindTheSecretItWasGiven(t *testing.T) {
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve a port: %v", err)
}
address := probe.Addr().String()
_ = probe.Close()
withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}})
t.Cleanup(func() { route.ReCreateServer(&route.Config{}) })
secret := "generated"
if err := updateConfig(&UpdateParams{ExternalController: &address, Secret: &secret}); err != nil {
t.Fatalf("updateConfig error: %v", err)
}
status := func(authorization string) int {
request, _ := http.NewRequest(http.MethodGet, "http://"+address+"/version", nil)
if authorization != "" {
request.Header.Set("Authorization", authorization)
}
deadline := time.Now().Add(3 * time.Second)
for {
response, err := http.DefaultClient.Do(request)
if err == nil {
_ = response.Body.Close()
return response.StatusCode
}
if time.Now().After(deadline) {
t.Fatalf("the controller never came up: %v", err)
}
time.Sleep(50 * time.Millisecond)
}
}
if got := status(""); got != http.StatusUnauthorized {
t.Errorf("without the secret: status %d, want %d", got, http.StatusUnauthorized)
}
if got := status("Bearer " + secret); got != http.StatusOK {
t.Errorf("with the secret: status %d, want %d", got, http.StatusOK)
}
}
// geoUpdaterCalls counts the reconciliation the code under test asked for. The
// real calls spawn a goroutine that downloads the GEO databases, which is not
// something a unit test should reach for.
type geoUpdaterCalls struct {
registered int
stopped int
}
func stubGeoUpdater(t *testing.T) *geoUpdaterCalls {
t.Helper()
calls := &geoUpdaterCalls{}
previousRegister, previousStop := registerGeoUpdater, stopGeoUpdater
previousAuto, previousInterval := updater.GeoAutoUpdate(), updater.GeoUpdateInterval()
registerGeoUpdater = func() { calls.registered++ }
stopGeoUpdater = func() { calls.stopped++ }
t.Cleanup(func() {
registerGeoUpdater, stopGeoUpdater = previousRegister, previousStop
updater.SetGeoAutoUpdate(previousAuto)
updater.SetGeoUpdateInterval(previousInterval)
})
return calls
}
func TestSyncGeoUpdaterStopsTheUpdaterWhenDisabled(t *testing.T) {
calls := stubGeoUpdater(t)
updater.SetGeoAutoUpdate(true)
updater.SetGeoUpdateInterval(24)
disabled := false
syncGeoUpdater(&disabled, nil)
if updater.GeoAutoUpdate() {
t.Error("GeoAutoUpdate stayed on after the setting was turned off")
}
if updater.GeoUpdateInterval() != 24 {
t.Errorf("GeoUpdateInterval = %d, want the configured 24 left untouched by the stop", updater.GeoUpdateInterval())
}
if calls.stopped != 1 || calls.registered != 0 {
t.Errorf(
"updater calls = %d registered/%d stopped, want the running updater cancelled",
calls.registered, calls.stopped,
)
}
}
func TestSyncGeoUpdaterIgnoresUnchangedParameters(t *testing.T) {
calls := stubGeoUpdater(t)
updater.SetGeoAutoUpdate(false)
updater.SetGeoUpdateInterval(12)
stillDisabled := false
sameInterval := 12
syncGeoUpdater(&stillDisabled, &sameInterval)
if updater.GeoAutoUpdate() || updater.GeoUpdateInterval() != 12 {
t.Errorf(
"geo settings = %v/%d, want them untouched",
updater.GeoAutoUpdate(), updater.GeoUpdateInterval(),
)
}
if calls.registered != 0 || calls.stopped != 0 {
t.Errorf(
"updater calls = %d registered/%d stopped, want the running updater left alone",
calls.registered, calls.stopped,
)
}
}
// A profile apply reloads the setting out of config.yaml, so the core can end
// up with the updater running while the flag reads off, and syncGeoUpdater sees
// nothing to change because the flag already matches what the app sends next.
func TestReconcileGeoUpdaterStopsAnUpdaterTheProfileTurnedOff(t *testing.T) {
calls := stubGeoUpdater(t)
updater.SetGeoAutoUpdate(false)
reconcileGeoUpdater()
if calls.stopped != 1 || calls.registered != 0 {
t.Errorf(
"updater calls = %d registered/%d stopped, want the updater cancelled",
calls.registered, calls.stopped,
)
}
}
func TestReconcileGeoUpdaterRegistersWhenTheSettingIsOn(t *testing.T) {
calls := stubGeoUpdater(t)
updater.SetGeoAutoUpdate(true)
reconcileGeoUpdater()
if calls.registered != 1 || calls.stopped != 0 {
t.Errorf(
"updater calls = %d registered/%d stopped, want the updater registered",
calls.registered, calls.stopped,
)
}
}
func TestCurrentTestURLFallsBackToTheBuiltInProbe(t *testing.T) {
previous := testURL.Load()
t.Cleanup(func() { testURL.Store(previous) })
testURL.Store(nil)
if got := currentTestURL(); got != defaultTestURL {
t.Errorf("currentTestURL = %q, want the built-in probe", got)
}
empty := ""
testURL.Store(&empty)
if got := currentTestURL(); got != defaultTestURL {
t.Errorf("currentTestURL = %q, want an empty setting to fall back", got)
}
custom := "https://example.test/generate_204"
setTestURL(custom)
if got := currentTestURL(); got != custom {
t.Errorf("currentTestURL = %q, want %q", got, custom)
}
}
func TestDelayTestSlotsBoundConcurrency(t *testing.T) {
if cap(delayTestSlots) != delayTestConcurrency {
t.Fatalf("delayTestSlots capacity = %d, want %d", cap(delayTestSlots), delayTestConcurrency)
}
for i := 0; i < delayTestConcurrency; i++ {
if !delayTestSlots.acquire(context.Background()) {
t.Fatalf("slot %d was refused while the semaphore still had room", i)
}
}
blocked := make(chan struct{})
go func() {
delayTestSlots.acquire(context.Background())
close(blocked)
}()
select {
case <-blocked:
t.Fatal("delayTestSlots handed out more slots than the configured concurrency")
case <-time.After(20 * time.Millisecond):
}
delayTestSlots.release()
select {
case <-blocked:
case <-time.After(time.Second):
t.Fatal("a released slot never reached the waiting caller")
}
for i := 0; i < delayTestConcurrency; i++ {
delayTestSlots.release()
}
}
func TestHandleUpdateGeoDataRejectsAnUnknownResource(t *testing.T) {
if err := handleUpdateGeoData("NOPE"); err == nil {
t.Error("an unknown geo resource reported success and silently did nothing")
}
if _, exists := geoResources["MMDB"]; !exists {
t.Error("MMDB is missing from the geo resource table")
}
}
func TestSetTestURLIgnoresAnEmptyValue(t *testing.T) {
const configured = "https://example.test/generate_204"
previousDefault := constant.DefaultTestURL
previousStored := testURL.Load()
t.Cleanup(func() {
constant.DefaultTestURL = previousDefault
testURL.Store(previousStored)
})
setTestURL(configured)
setTestURL("")
if constant.DefaultTestURL != configured {
t.Errorf("DefaultTestURL = %q, an empty setup param cleared mihomo's own default", constant.DefaultTestURL)
}
if got := currentTestURL(); got != configured {
t.Errorf("currentTestURL() = %q, want %q", got, configured)
}
}
func TestTestDelayRejectsAnUnknownProxyWithoutWaitingForASlot(t *testing.T) {
for i := 0; i < delayTestConcurrency; i++ {
delayTestSlots <- struct{}{}
}
t.Cleanup(func() {
for i := 0; i < delayTestConcurrency; i++ {
<-delayTestSlots
}
})
done := make(chan *Delay, 1)
go func() {
done <- handleTestDelay(&TestDelayParams{ProxyName: "missing", Timeout: 1})
}()
select {
case delay := <-done:
if delay.Value != delayFailed {
t.Errorf("value = %d, want %d", delay.Value, delayFailed)
}
case <-time.After(time.Second):
t.Fatal("an unknown proxy queued behind a saturated delay-test semaphore")
}
}
func settleMessageBatcher() {
time.Sleep(3 * messageBatchInterval)
}
func withGeoResourceUpdaters(t *testing.T, updaters map[string]func() error) {
t.Helper()
previous := geoResources
geoResources = make(map[string]geoResource, len(updaters))
for name, update := range updaters {
geoResources[name] = geoResource{update: update}
}
t.Cleanup(func() {
geoResources = previous
for name := range updaters {
releaseGeoUpdate(name)
}
})
}
func TestHandleUpdateGeoDataRunsOneUpdatePerResource(t *testing.T) {
const resource = "TEST"
started := make(chan struct{}, 2)
release := make(chan struct{})
var calls atomic.Int32
withGeoResourceUpdaters(t, map[string]func() error{
resource: func() error {
calls.Add(1)
started <- struct{}{}
<-release
return nil
},
})
if err := handleUpdateGeoData(resource); err != nil {
t.Fatalf("handleUpdateGeoData = %v, want no error", err)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("the first update never started")
}
if err := handleUpdateGeoData(resource); err == nil {
t.Fatal("the duplicate request reported no error while an update was already in flight")
}
select {
case <-started:
t.Fatal("a second update started while the first was still running; both close the mmap'd database")
case <-time.After(100 * time.Millisecond):
}
close(release)
if got := calls.Load(); got != 1 {
t.Errorf("the updater ran %d times, want 1", got)
}
deadline := time.Now().Add(time.Second)
for !claimGeoUpdate(resource) {
if time.Now().After(deadline) {
t.Fatal("the in-flight claim was never released, so the resource can never be updated again")
}
time.Sleep(5 * time.Millisecond)
}
releaseGeoUpdate(resource)
}
func TestGeoUpdateHookBlocksAManualUpdateOfTheSameResource(t *testing.T) {
const resource = "MMDB"
ran := make(chan struct{}, 1)
withGeoResourceUpdaters(t, map[string]func() error{
resource: func() error {
ran <- struct{}{}
return nil
},
})
updater.GeoUpdateHook(resource, true, false, nil)
if err := handleUpdateGeoData(resource); err == nil {
t.Fatal("handleUpdateGeoData reported no error while the hook already claimed this resource")
}
select {
case <-ran:
t.Fatal("a manual update ran while the auto updater was already updating the same resource")
case <-time.After(100 * time.Millisecond):
}
updater.GeoUpdateHook(resource, false, false, nil)
if !claimGeoUpdate(resource) {
t.Fatal("the hook never cleared the claim, so the resource stays blocked for the rest of the process")
}
releaseGeoUpdate(resource)
settleMessageBatcher()
}
// The hook fires for the automatic updater, which can start and finish inside a
// manual update of the same resource. Releasing the manual claim there lets a
// second manual update run alongside the first.
func TestGeoUpdateHookDoesNotReleaseAManualClaim(t *testing.T) {
const resource = "GEOIP"
started := make(chan struct{}, 2)
release := make(chan struct{})
var calls atomic.Int32
withGeoResourceUpdaters(t, map[string]func() error{
resource: func() error {
calls.Add(1)
started <- struct{}{}
<-release
return nil
},
})
if err := handleUpdateGeoData(resource); err != nil {
t.Fatalf("handleUpdateGeoData = %v, want no error", err)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("the manual update never started")
}
updater.GeoUpdateHook(resource, true, false, nil)
updater.GeoUpdateHook(resource, false, false, nil)
if err := handleUpdateGeoData(resource); err == nil {
t.Fatal("the duplicate request reported no error while the manual claim was still held")
}
select {
case <-started:
t.Fatal("a second manual update started while the first was still running")
case <-time.After(100 * time.Millisecond):
}
close(release)
deadline := time.Now().Add(time.Second)
for !claimGeoUpdate(resource) {
if time.Now().After(deadline) {
t.Fatal("the manual claim was never released")
}
time.Sleep(5 * time.Millisecond)
}
releaseGeoUpdate(resource)
if got := calls.Load(); got != 1 {
t.Errorf("the updater ran %d times, want 1", got)
}
settleMessageBatcher()
}
// An empty config.yaml is how the app expresses "no profile selected". mihomo's
// own reader rejects it.
func TestLoadConfigTreatsAnEmptyProfileAsTheDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := loadConfig(path)
if err != nil {
t.Fatalf("loadConfig = %v, want no error for an empty profile", err)
}
if cfg == nil {
t.Fatal("loadConfig returned no config for an empty profile")
}
}
func TestLoadConfigReportsAMissingFile(t *testing.T) {
_, err := loadConfig(filepath.Join(t.TempDir(), "absent.yaml"))
if err == nil {
t.Fatal("loadConfig accepted a path that does not exist")
}
if !strings.Contains(err.Error(), "absent.yaml") {
t.Errorf("loadConfig = %v, want it to name the missing file", err)
}
}
func TestLoadConfigReportsMalformedYaml(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("proxies: [unterminated\n"), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
if _, err := loadConfig(path); err == nil {
t.Fatal("loadConfig accepted malformed yaml")
}
}
func TestLoadConfigKeepsTheSystemTimeWriteOnlyWhereItIsAllowed(t *testing.T) {
previous := systemTimeWritable
t.Cleanup(func() { systemTimeWritable = previous })
path := filepath.Join(t.TempDir(), "config.yaml")
profile := "ntp:\n enable: true\n write-to-system: true\n"
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
for _, writable := range []bool{true, false} {
systemTimeWritable = writable
cfg, err := loadConfig(path)
if err != nil {
t.Fatalf("loadConfig = %v", err)
}
if cfg.NTP.WriteToSystem != writable {
t.Errorf("writable %t: WriteToSystem = %t", writable, cfg.NTP.WriteToSystem)
}
if !cfg.NTP.Enable {
t.Errorf("writable %t: NTP was disabled, want only the system write dropped", writable)
}
}
}
func withSetupConfig(t *testing.T, apply func(*SetupParams) error) {
t.Helper()
previous := setupConfig
setupConfig = apply
t.Cleanup(func() { setupConfig = previous })
isInit.Store(true)
isRunning.Store(true)
t.Cleanup(func() {
isInit.Store(false)
isRunning.Store(false)
})
}
// applyConfig rolls back to the default config when the profile fails to parse.
// That rollback is the whole recovery: the error reaches the host and the
// listeners keep serving while the user picks another profile.
func TestHandleSetupConfigKeepsTheListenerWhenTheProfileFails(t *testing.T) {
withSetupConfig(t, func(*SetupParams) error { return errors.New("bad profile") })
err := handleSetupConfig(defaultSetupParams())
if err == nil || err.Error() != "bad profile" {
t.Fatalf("handleSetupConfig = %v, want the apply error", err)
}
if !isRunning.Load() {
t.Error("a failed apply stopped the listeners")
}
}
func TestHandleSetupConfigLeavesTheListenerAloneOnSuccess(t *testing.T) {
withSetupConfig(t, func(*SetupParams) error { return nil })
if err := handleSetupConfig(defaultSetupParams()); err != nil {
t.Fatalf("handleSetupConfig = %v, want no error", err)
}
if !isRunning.Load() {
t.Error("a successful apply stopped the listeners")
}
}
func TestSlotsAcquireGivesUpOnTheDeadline(t *testing.T) {
for i := 0; i < delayTestConcurrency; i++ {
delayTestSlots <- struct{}{}
}
t.Cleanup(func() {
for i := 0; i < delayTestConcurrency; i++ {
<-delayTestSlots
}
})
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
refused := make(chan bool, 1)
go func() { refused <- delayTestSlots.acquire(ctx) }()
select {
case granted := <-refused:
if granted {
t.Fatal("acquire handed out a slot the semaphore did not have")
}
case <-time.After(time.Second):
t.Fatal("acquire ignored the deadline and kept queueing")
}
}
func TestTimeoutFromMillisFallsBackWhenUnset(t *testing.T) {
if got := timeoutFromMillis(250, defaultDelayTestTimeout); got != 250*time.Millisecond {
t.Errorf("timeoutFromMillis(250) = %v, want 250ms", got)
}
for _, unset := range []int64{0, -1} {
if got := timeoutFromMillis(unset, defaultDelayTestTimeout); got != defaultDelayTestTimeout {
t.Errorf("timeoutFromMillis(%d) = %v, want the default %v", unset, got, defaultDelayTestTimeout)
}
}
}
func TestTestDelayQueuesWhenTheTimeoutIsUnset(t *testing.T) {
for i := 0; i < delayTestConcurrency; i++ {
delayTestSlots <- struct{}{}
}
t.Cleanup(func() {
for i := 0; i < delayTestConcurrency; i++ {
<-delayTestSlots
}
})
tunnel.UpdateProxies(map[string]constant.Proxy{"queued": namedProxy("queued")}, nil)
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
done := make(chan *Delay, 1)
go func() {
// A refused port rather than the default URL: the probe this is proving
// will eventually happen should not leave the machine.
done <- handleTestDelay(&TestDelayParams{
ProxyName: "queued",
TestUrl: "http://127.0.0.1:1",
})
}()
select {
case <-done:
t.Fatal("handleTestDelay gave up immediately on an unset timeout, want it queueing for the default")
case <-time.After(200 * time.Millisecond):
}
// Hand over a slot and join, so nothing is still reading the tunnel when
// the cleanup below replaces it. tunnel.Proxies and tunnel.Providers are
// unsynchronised reads of maps UpdateProxies writes, so an unjoined reader
// is a reported race whatever the timing.
<-delayTestSlots
delay := <-done
delayTestSlots <- struct{}{}
if delay.Value != delayFailed {
t.Errorf("value = %d, want %d for a probe against a refused port", delay.Value, delayFailed)
}
}
// blackHoleServer accepts connections and then says nothing, so a probe against
// it connects and waits out its deadline rather than failing fast the way a
// refused port would.
func blackHoleServer(t *testing.T) net.Addr {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
var conns []net.Conn
accepting := make(chan struct{})
go func() {
defer close(accepting)
for {
conn, err := listener.Accept()
if err != nil {
return
}
conns = append(conns, conn)
}
}()
t.Cleanup(func() {
_ = listener.Close()
// Join the accept loop before touching conns: the close above is what
// ends it, and the receive is the only ordering between the two.
<-accepting
for _, conn := range conns {
_ = conn.Close()
}
})
return listener.Addr()
}
// Queueing for a slot and probing the node must not share one deadline: a node
// that waited out most of its timeout behind a saturated semaphore would have
// only the remainder to connect in and report Timeout while perfectly healthy -
// which is what a bulk test of a large subscription does to everything at the
// back of the queue.
func TestTestDelayDoesNotSpendTheProbeBudgetQueueing(t *testing.T) {
const (
timeout = 200 * time.Millisecond
queueFor = 150 * time.Millisecond
)
addr := blackHoleServer(t)
for i := 0; i < delayTestConcurrency; i++ {
delayTestSlots <- struct{}{}
}
t.Cleanup(func() {
for i := 0; i < delayTestConcurrency; i++ {
<-delayTestSlots
}
})
tunnel.UpdateProxies(map[string]constant.Proxy{"queued": namedProxy("queued")}, nil)
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
done := make(chan *Delay, 1)
start := time.Now()
go func() {
done <- handleTestDelay(&TestDelayParams{
ProxyName: "queued",
TestUrl: "http://" + addr.String(),
Timeout: timeout.Milliseconds(),
})
}()
time.Sleep(queueFor)
<-delayTestSlots
select {
case delay := <-done:
elapsed := time.Since(start)
if delay == nil {
t.Fatal("handleTestDelay gave up queueing even though a slot came free")
}
if elapsed < queueFor+timeout {
t.Errorf(
"handleTestDelay returned after %v, want at least %v: the probe inherited the deadline the wait had already spent",
elapsed,
queueFor+timeout,
)
}
if delay.Value != delayTimedOut {
t.Errorf("value = %d, want %d for a probe that waited out its deadline", delay.Value, delayTimedOut)
}
case <-time.After(5 * time.Second):
t.Fatal("handleTestDelay never returned")
}
// handleTestDelay released the slot it was handed; put it back so the
// cleanup above drains what it put in.
delayTestSlots <- struct{}{}
}
func TestTestDelayStopsQueueingOnceTheTimeoutIsSpent(t *testing.T) {
for i := 0; i < delayTestConcurrency; i++ {
delayTestSlots <- struct{}{}
}
t.Cleanup(func() {
for i := 0; i < delayTestConcurrency; i++ {
<-delayTestSlots
}
})
// A proxy the lookup actually resolves, so the call reaches the semaphore
// instead of bailing out on an unknown name.
tunnel.UpdateProxies(map[string]constant.Proxy{"queued": namedProxy("queued")}, nil)
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
done := make(chan *Delay, 1)
go func() {
done <- handleTestDelay(&TestDelayParams{ProxyName: "queued", Timeout: 20})
}()
select {
case delay := <-done:
if delay != nil {
t.Errorf("delay = %+v, want no result for a test that never got a slot", delay)
}
case <-time.After(time.Second):
t.Fatal("handleTestDelay outlived the timeout it was given, waiting for a slot")
}
}