260 lines
8.0 KiB
Go
260 lines
8.0 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func TestPublishCandidateGeneratesValidatedLastGood(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var lock sync.RWMutex
|
|
response := fullSubscription("first-node")
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
lock.RLock()
|
|
defer lock.RUnlock()
|
|
_, _ = writer.Write([]byte(response))
|
|
}))
|
|
defer server.Close()
|
|
|
|
config := candidateFixture(t, server.URL+"?token=FAKE-SECRET")
|
|
if err := PublishCandidate(context.Background(), config); err != nil {
|
|
t.Fatalf("PublishCandidate() error = %v", err)
|
|
}
|
|
firstTarget := readLastGood(t, config.DataDir)
|
|
if firstTarget != "generations/a" {
|
|
t.Fatalf("last-good target = %q, want generations/a", firstTarget)
|
|
}
|
|
firstDir := filepath.Join(config.DataDir, filepath.FromSlash(firstTarget))
|
|
assertContains(t, filepath.Join(firstDir, "config.yaml"), "external-controller: 0.0.0.0:9090")
|
|
assertContains(t, filepath.Join(firstDir, "subscription.yaml"), "name: first-node")
|
|
assertNotContains(t, filepath.Join(firstDir, "subscription.yaml"), "proxy-groups:")
|
|
for _, name := range []string{"config.yaml", "subscription.yaml"} {
|
|
info, err := os.Stat(filepath.Join(firstDir, name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != 0o600 {
|
|
t.Errorf("%s mode = %o, want 600", name, info.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
lock.Lock()
|
|
response = fullSubscription("second-node")
|
|
lock.Unlock()
|
|
if err := PublishCandidate(context.Background(), config); err != nil {
|
|
t.Fatalf("second PublishCandidate() error = %v", err)
|
|
}
|
|
secondTarget := readLastGood(t, config.DataDir)
|
|
if secondTarget != "generations/b" {
|
|
t.Fatalf("last-good target = %q, want generations/b", secondTarget)
|
|
}
|
|
assertContains(t, filepath.Join(config.DataDir, filepath.FromSlash(secondTarget), "subscription.yaml"), "name: second-node")
|
|
assertContains(t, filepath.Join(firstDir, "subscription.yaml"), "name: first-node")
|
|
}
|
|
|
|
func TestPublishCandidateFailureMatrixKeepsLastGoodAndRedactsInput(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
response string
|
|
status int
|
|
secret string
|
|
template string
|
|
transport bool
|
|
}{
|
|
{name: "invalid secret URL", secret: "not-a-url-FAKE-SECRET"},
|
|
{name: "request failure", transport: true},
|
|
{name: "HTTP failure", status: http.StatusServiceUnavailable},
|
|
{name: "empty response"},
|
|
{name: "oversized response", response: strings.Repeat("x", maxSubscriptionSize+1)},
|
|
{name: "invalid YAML", response: "proxies: ["},
|
|
{name: "missing proxies", response: "proxy-groups: []\n"},
|
|
{name: "generation failure", response: fullSubscription("new-node"), template: "[]\n"},
|
|
{name: "Mihomo rejection", response: fullSubscription("reject-validation")},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
var lock sync.RWMutex
|
|
response := fullSubscription("last-good-node")
|
|
status := http.StatusOK
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
lock.RLock()
|
|
defer lock.RUnlock()
|
|
writer.WriteHeader(status)
|
|
_, _ = writer.Write([]byte(response))
|
|
}))
|
|
defer server.Close()
|
|
|
|
config := candidateFixture(t, server.URL+"?token=FAKE-SECRET")
|
|
if err := PublishCandidate(context.Background(), config); err != nil {
|
|
t.Fatalf("initial PublishCandidate() error = %v", err)
|
|
}
|
|
wantTarget := readLastGood(t, config.DataDir)
|
|
wantSubscription, err := os.ReadFile(filepath.Join(config.DataDir, filepath.FromSlash(wantTarget), "subscription.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
lock.Lock()
|
|
response = testCase.response
|
|
if testCase.status != 0 {
|
|
status = testCase.status
|
|
}
|
|
lock.Unlock()
|
|
if testCase.secret != "" {
|
|
if err := os.WriteFile(config.SecretPath, []byte(testCase.secret), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if testCase.template != "" {
|
|
config.TemplatePath = writeFixture(t, t.TempDir(), "config.yaml", testCase.template)
|
|
}
|
|
if testCase.transport {
|
|
config.Client = &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
return nil, errors.New("FAKE-SECRET transport detail")
|
|
})}
|
|
}
|
|
|
|
err = PublishCandidate(context.Background(), config)
|
|
if err == nil {
|
|
t.Fatal("PublishCandidate() error = nil")
|
|
}
|
|
if strings.Contains(err.Error(), "FAKE-SECRET") || strings.Contains(err.Error(), "reject-validation") {
|
|
t.Fatalf("PublishCandidate() leaked sensitive input: %v", err)
|
|
}
|
|
if got := readLastGood(t, config.DataDir); got != wantTarget {
|
|
t.Fatalf("last-good target = %q, want unchanged %q", got, wantTarget)
|
|
}
|
|
gotSubscription, err := os.ReadFile(filepath.Join(config.DataDir, filepath.FromSlash(wantTarget), "subscription.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(gotSubscription) != string(wantSubscription) {
|
|
t.Fatal("failed update changed last-good subscription")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublishCandidateRejectsUnmanagedLastGood(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
_, _ = writer.Write([]byte(fullSubscription("new-node")))
|
|
}))
|
|
defer server.Close()
|
|
config := candidateFixture(t, server.URL)
|
|
if err := os.MkdirAll(config.DataDir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFixture(t, config.DataDir, "last-good", "operator-owned")
|
|
|
|
err := PublishCandidate(context.Background(), config)
|
|
if err == nil || !strings.Contains(err.Error(), "managed symlink") {
|
|
t.Fatalf("PublishCandidate() error = %v, want unmanaged last-good rejection", err)
|
|
}
|
|
assertFileContent(t, filepath.Join(config.DataDir, "last-good"), "operator-owned")
|
|
}
|
|
|
|
func candidateFixture(t *testing.T, endpoint string) CandidateConfig {
|
|
t.Helper()
|
|
tempDir := t.TempDir()
|
|
secret := writeFixture(t, tempDir, "subscription-secret", endpoint+"\n")
|
|
mihomo := writeFixture(t, tempDir, "mihomo", `#!/bin/sh
|
|
set -eu
|
|
test "$1" = -t
|
|
directory=
|
|
config=
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
-d) directory=$2; shift 2 ;;
|
|
-f) config=$2; shift 2 ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
test -n "$directory" -a -n "$config"
|
|
grep -F 'external-controller: 0.0.0.0:9090' "$config" >/dev/null
|
|
grep -F 'proxies:' "$directory/subscription.yaml" >/dev/null
|
|
! grep -F 'reject-validation' "$directory/subscription.yaml" >/dev/null
|
|
`)
|
|
if err := os.Chmod(mihomo, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return CandidateConfig{
|
|
SecretPath: secret,
|
|
DataDir: filepath.Join(tempDir, "data"),
|
|
TemplatePath: filepath.Join("..", "..", "config", "config.yaml"),
|
|
MihomoBinary: mihomo,
|
|
}
|
|
}
|
|
|
|
func fullSubscription(name string) string {
|
|
return "mixed-port: 1234\nproxies:\n - name: " + name + "\n type: socks5\n server: 127.0.0.1\n port: 9\nproxy-groups: []\n"
|
|
}
|
|
|
|
func readLastGood(t *testing.T, dataDir string) string {
|
|
t.Helper()
|
|
target, err := os.Readlink(filepath.Join(dataDir, "last-good"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return target
|
|
}
|
|
|
|
func assertContains(t *testing.T, path, want string) {
|
|
t.Helper()
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(content), want) {
|
|
t.Errorf("%s does not contain %q", path, want)
|
|
}
|
|
}
|
|
|
|
func assertNotContains(t *testing.T, path, unwanted string) {
|
|
t.Helper()
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(content), unwanted) {
|
|
t.Errorf("%s contains %q", path, unwanted)
|
|
}
|
|
}
|
|
|
|
func assertFileContent(t *testing.T, path, want string) {
|
|
t.Helper()
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(content) != want {
|
|
t.Fatalf("%s = %q, want %q", path, content, want)
|
|
}
|
|
}
|
|
|
|
func writeFixture(t *testing.T, directory, name, content string) string {
|
|
t.Helper()
|
|
if err := os.MkdirAll(directory, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(directory, name)
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (function roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
return function(request)
|
|
}
|