package main import ( "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "errors" "math/big" "net" "os" "path/filepath" "sort" "strings" "testing" "time" "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/platform" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" ) // installerTOML is felis.toml as deploy/bootstrap.sh write_felis_toml renders it, // comments included: the domain move has to leave all of it but three values alone. func installerTOML(root, dbHost string) string { return `# Generated by deploy/bootstrap.sh; rerun the installer to regenerate. Hand edits are # overwritten, except [smtp], [[auth_source]], [offsite], and the operator-owned # [registry] / [archive] overrides, which carry forward. [server] listen = "0.0.0.0:8080" root_domain = "` + root + `" [database] url = "postgres://felis:pw@` + dbHost + `:5432/felis?sslmode=disable" [k8s] namespace = "minecraft" egress_mode = "nodeport" [velocity] # The two always-on system servers that felis setup provisions. login_image = "felis/limbo:1" lobby_image = "felis/lobby:1" game_port = 25565 [registry] url = "registry.felis.svc:5000" build_namespace = "felis-build" [archive] store = "tarLocal" local_path = "/var/lib/felis/archives" [auth] admin_hostname = "op.console.` + root + `" panel_hostname = "console.` + root + `" access_jwt_aud = "aud123" # Third-party Yggdrasil sources federated by the hasJoined multiplexer. [[auth_source]] tag = "littleskin" prefix = "LS" url = "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined" ` } const linkPropsBody = `# Generated by deploy/bootstrap.sh — do not edit by hand; rerun the installer. api-base-url=http://10.43.0.9:8081 service-token=TOKEN-NOT-TO-TOUCH root-domain=old.example panel-hostname=console.old.example admin-hostname=op.console.old.example login-server=login lobby-server=lobby ` var oldNames = domainNames{root: "old.example", panel: "console.old.example", admin: "op.console.old.example"} var newNames = domainNames{root: "new.example", panel: "console.new.example", admin: "op.console.new.example"} // domainRig models the host: the files, the cluster, and a felis-api, proxy and // operator that pick up config the way the real ones do — the api serves what it // read at its last restart, the proxy runs since its last restart, and the login // pod carries the env its MinecraftServer had when it was last rolled. type domainRig struct { h domainHost cl client.Client out *bytes.Buffer dir string events []string served domainNames servedCert *x509.Certificate proxySince time.Time proxyLoaded bool unresolved map[string]bool // The fake operator: the CR env the login pod was last rolled to, and how // many looks at the pod since the CR moved on. rolledTo string pending int } func (rig *domainRig) path(name string) string { return filepath.Join(rig.dir, name) } func newDomainRig(t *testing.T) *domainRig { t.Helper() rig := &domainRig{out: &bytes.Buffer{}, dir: t.TempDir(), proxyLoaded: true, unresolved: map[string]bool{}} writeTestFile(t, rig.path("felis.host.toml"), installerTOML("old.example", "127.0.0.1"), 0o600) writeTestFile(t, rig.path("felis.pod.toml"), installerTOML("old.example", "10.211.55.6"), 0o600) if err := os.Symlink(rig.path("felis.host.toml"), rig.path("felis.toml")); err != nil { t.Fatal(err) } certPEM, keyPEM, err := issuePanelCert(oldNames, []net.IP{net.ParseIP("10.211.55.6")}, time.Now()) if err != nil { t.Fatal(err) } writeTestFile(t, rig.path("panel-tls.crt"), string(certPEM), 0o644) writeTestFile(t, rig.path("panel-tls.key"), string(keyPEM), 0o600) writeTestFile(t, rig.path("felis-link.properties"), linkPropsBody, 0o640) // The proxy started before its config was last written, which is how it // stands after an install. rig.proxySince = time.Now().Add(-time.Hour) pod := []byte(installerTOML("old.example", "10.211.55.6")) login, err := loginSystemServer("felis/limbo:1", "minecraft", platform.InternalAPIBaseURL("felis"), oldNames.root, oldNames.panel) if err != nil { t.Fatal(err) } lobby, err := lobbySystemServer("felis/lobby:1", "minecraft") if err != nil { t.Fatal(err) } loginPod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Namespace: "minecraft", Name: naming.SystemLoginServer + "-0"}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "minecraft", Env: podEnv(login.Spec.Env)}}}, Status: corev1.PodStatus{Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}}, } rig.rolledTo = envKey(login.Spec.Env) rig.cl = fake.NewClientBuilder().WithScheme(haltScheme(t)).WithInterceptorFuncs(interceptor.Funcs{ Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { if key.Name == naming.SystemLoginServer+"-0" { rig.operatorTick(t, c) } return c.Get(ctx, key, obj, opts...) }, }).WithObjects( &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "felis", Name: platform.ConfigSecretName}, Data: map[string][]byte{platform.ConfigSecretKey: pod}}, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "minecraft", Name: platform.ConfigSecretName}, Data: map[string][]byte{platform.ConfigSecretKey: pod}}, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "felis", Name: platform.APITLSSecretName}, Type: corev1.SecretTypeTLS, Data: map[string][]byte{corev1.TLSCertKey: certPEM, corev1.TLSPrivateKeyKey: keyPEM}}, login, lobby, loginPod, ).Build() rig.served = oldNames rig.servedCert, _ = x509.ParseCertificate(mustCertDER(certPEM)) rig.h = domainHost{ paths: domainPaths{ hostTOML: rig.path("felis.host.toml"), podTOML: rig.path("felis.pod.toml"), defaultTOML: rig.path("felis.toml"), cert: rig.path("panel-tls.crt"), key: rig.path("panel-tls.key"), linkProps: rig.path("felis-link.properties"), tunnelConfig: rig.path("cloudflared.yml"), }, cl: rig.cl, controlNS: "felis", rollAPI: func(ctx context.Context) error { rig.events = append(rig.events, "roll-api") rig.restartAPI(t) return nil }, restartUnit: func(_ context.Context, unit string) error { rig.events = append(rig.events, "restart "+unit) rig.proxySince = time.Now().Add(time.Second) return nil }, unitState: func(context.Context, string) (unitStatus, error) { return unitStatus{loaded: rig.proxyLoaded, active: rig.proxyLoaded, since: rig.proxySince}, nil }, liveAPI: func(context.Context, string) (liveAPIView, error) { return liveAPIView{names: rig.served, cert: rig.servedCert}, nil }, lookupHost: func(_ context.Context, host string) ([]string, error) { if rig.unresolved[host] { return nil, errors.New("no such host") } return []string{"10.211.55.6"}, nil }, passkeys: func(context.Context) (int, int, error) { return 3, 2, nil }, now: time.Now, out: rig.out, loginWait: 50 * time.Millisecond, pollEvery: time.Millisecond, } return rig } func podEnv(env []v1alpha1.EnvVar) []corev1.EnvVar { out := make([]corev1.EnvVar, len(env)) for i, e := range env { out[i] = corev1.EnvVar{Name: e.Name, Value: e.Value} } return out } // restartAPI makes the fake felis-api load the config and certificate its // Secrets hold now. func (rig *domainRig) restartAPI(t *testing.T) { t.Helper() var cfg, tlsSec corev1.Secret ctx := context.Background() if err := rig.cl.Get(ctx, client.ObjectKey{Namespace: "felis", Name: platform.ConfigSecretName}, &cfg); err != nil { t.Fatal(err) } if err := rig.cl.Get(ctx, client.ObjectKey{Namespace: "felis", Name: platform.APITLSSecretName}, &tlsSec); err != nil { t.Fatal(err) } names, err := tomlDomainNames(cfg.Data[platform.ConfigSecretKey]) if err != nil { t.Fatal(err) } rig.served = names rig.servedCert, err = x509.ParseCertificate(mustCertDER(tlsSec.Data[corev1.TLSCertKey])) if err != nil { t.Fatal(err) } } // rollLoginPod is the operator restarting the login pod onto its CR's env. // operatorTick is the operator as the login pod is watched: once the CR's env // changes it takes operatorLag looks at the pod before the restarted pod // carries the new env, the way a real rollout lags the CR. func (rig *domainRig) operatorTick(t *testing.T, c client.Client) { t.Helper() ctx := context.Background() var ms v1alpha1.MinecraftServer if err := c.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: naming.SystemLoginServer}, &ms); err != nil { t.Fatal(err) } if envKey(ms.Spec.Env) == rig.rolledTo { return } if rig.pending++; rig.pending < operatorLag { return } var pod corev1.Pod if err := c.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: naming.SystemLoginServer + "-0"}, &pod); err != nil { t.Fatal(err) } pod.Spec.Containers[0].Env = podEnv(ms.Spec.Env) if err := c.Update(ctx, &pod); err != nil { t.Fatal(err) } rig.rolledTo, rig.pending = envKey(ms.Spec.Env), 0 } const operatorLag = 3 func envKey(env []v1alpha1.EnvVar) string { var b strings.Builder for _, e := range env { b.WriteString(e.Name + "=" + e.Value + "\n") } return b.String() } func (rig *domainRig) read(t *testing.T, name string) string { t.Helper() b, err := os.ReadFile(rig.path(name)) if err != nil { t.Fatal(err) } return string(b) } func (rig *domainRig) secret(t *testing.T, ns, name string) map[string][]byte { t.Helper() var s corev1.Secret if err := rig.cl.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: name}, &s); err != nil { t.Fatal(err) } return s.Data } func (rig *domainRig) crEnv(t *testing.T, name string) map[string]string { t.Helper() var ms v1alpha1.MinecraftServer if err := rig.cl.Get(context.Background(), client.ObjectKey{Namespace: "minecraft", Name: name}, &ms); err != nil { t.Fatal(err) } env := map[string]string{} for _, e := range ms.Spec.Env { env[e.Name] = e.Value } return env } // snapshot is every byte `set` may touch, for proving a refused or dry run // touched none of it. func (rig *domainRig) snapshot(t *testing.T) string { t.Helper() var b strings.Builder entries, _ := os.ReadDir(rig.dir) for _, e := range entries { b.WriteString(e.Name() + "\n" + rig.read(t, e.Name()) + "\n") } var lines []string for _, s := range []struct{ ns, name string }{{"felis", platform.ConfigSecretName}, {"minecraft", platform.ConfigSecretName}, {"felis", platform.APITLSSecretName}} { for k, v := range rig.secret(t, s.ns, s.name) { lines = append(lines, s.ns+"/"+s.name+"/"+k+"\n"+string(v)) } } for k, v := range rig.crEnv(t, naming.SystemLoginServer) { lines = append(lines, "env "+k+"="+v) } sort.Strings(lines) b.WriteString(strings.Join(lines, "\n")) return b.String() } func TestNormalizeRootDomain(t *testing.T) { for in, want := range map[string]string{ "Example.COM.": "example.com", " mc.example.org ": "mc.example.org", "10.211.55.6.nip.io": "10.211.55.6.nip.io", "xn--bcher-kva.example": "xn--bcher-kva.example", } { got, err := normalizeRootDomain(in) if err != nil || got != want { t.Errorf("normalizeRootDomain(%q) = %q, %v; want %q", in, got, err, want) } } for _, in := range []string{"", "https://example.com", "example.com:443", "example.com/x", "10.0.0.1", "::1", "localhost", "a_b.example", "-a.example", "a-.example", strings.Repeat("a", 64) + ".example", strings.Repeat("abcdefghi.", 25) + "example"} { if got, err := normalizeRootDomain(in); err == nil { t.Errorf("normalizeRootDomain(%q) = %q, want an error", in, got) } } } func TestPlanDomainChangeMovesDefaultsAndKeepsHandSetNames(t *testing.T) { p := planDomainChange(oldNames, "new.example") if p.to != newNames || p.customPanel || p.customAdmin { t.Fatalf("defaults: %+v", p) } p = planDomainChange(domainNames{root: "old.example", panel: "play.corp.net", admin: "op.console.old.example"}, "new.example") if p.to.panel != "play.corp.net" || !p.customPanel || p.to.admin != "op.console.new.example" || p.customAdmin { t.Fatalf("hand-set panel: %+v", p) } p = planDomainChange(domainNames{root: "old.example", panel: "console.old.example", admin: "admin.corp.net"}, "new.example") if p.to.admin != "admin.corp.net" || !p.customAdmin || p.to.panel != "console.new.example" { t.Fatalf("hand-set admin: %+v", p) } } func TestEditTOMLStringsChangesOnlyTheDomainLines(t *testing.T) { orig := installerTOML("old.example", "127.0.0.1") out, err := editTOMLStrings([]byte(orig), domainTOMLEdits(newNames)) if err != nil { t.Fatal(err) } a, b := strings.Split(orig, "\n"), strings.Split(string(out), "\n") if len(a) != len(b) { t.Fatalf("line count %d → %d:\n%s", len(a), len(b), out) } changed := map[string]string{} for i := range a { if a[i] != b[i] { changed[a[i]] = b[i] } } want := map[string]string{ `root_domain = "old.example"`: `root_domain = "new.example"`, `admin_hostname = "op.console.old.example"`: `admin_hostname = "op.console.new.example"`, `panel_hostname = "console.old.example"`: `panel_hostname = "console.new.example"`, } if len(changed) != len(want) { t.Fatalf("changed lines %v, want %v", changed, want) } for k, v := range want { if changed[k] != v { t.Errorf("%q → %q, want %q", k, changed[k], v) } } } func TestEditTOMLStringsAddsMissingKeysInTheirTable(t *testing.T) { in := "[server]\nroot_domain = \"old.example\"\n\n[auth]\naccess_jwt_aud = \"x\"\n\n[smtp]\nhost = \"relay\"\n" out, err := editTOMLStrings([]byte(in), domainTOMLEdits(newNames)) if err != nil { t.Fatal(err) } want := "[server]\nroot_domain = \"new.example\"\n\n[auth]\naccess_jwt_aud = \"x\"\npanel_hostname = \"console.new.example\"\nadmin_hostname = \"op.console.new.example\"\n\n[smtp]\nhost = \"relay\"\n" if string(out) != want { t.Fatalf("got:\n%s\nwant:\n%s", out, want) } out, err = editTOMLStrings([]byte("[server]\nroot_domain = \"old.example\"\n\n[[auth_source]]\ntag = \"ls\"\n"), domainTOMLEdits(newNames)) if err != nil { t.Fatal(err) } got, err := tomlDomainNames(out) if err != nil || got != newNames || !strings.Contains(string(out), "[[auth_source]]\ntag = \"ls\"\n") { t.Fatalf("no [auth] table: %v %+v\n%s", err, got, out) } } func TestEditTOMLStringsRefusesWhatItCannotEditExactly(t *testing.T) { for name, in := range map[string]string{ "multi-line value": "[server]\nroot_domain = \"\"\"\nold.example\"\"\"\n[auth]\n", // The key's line sits inside another value; the real key is absent. "key inside a string": "[server]\nmotd = \"\"\"\nroot_domain = \"old.example\"\n\"\"\"\n[auth]\n", "quoted header": "[server]\nroot_domain = \"old.example\"\n[\"auth\"]\npanel_hostname = \"console.old.example\"\n", "dotted key": "server.root_domain = \"old.example\"\n", "inline table": "server = { root_domain = \"old.example\" }\n", } { if out, err := editTOMLStrings([]byte(in), domainTOMLEdits(newNames)); err == nil { t.Errorf("%s: edited instead of refusing:\n%s", name, out) } } } // caSignedCert is an operator's certificate from their own CA; it names // localhost too, so only the issuer tells it apart from the installer's. func caSignedCert(t *testing.T, hosts ...string) (certPEM, keyPEM []byte) { t.Helper() caKey, _ := rsa.GenerateKey(rand.Reader, 2048) ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Corp CA"}, IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} caDER, err := x509.CreateCertificate(rand.Reader, ca, ca, &caKey.PublicKey, caKey) if err != nil { t.Fatal(err) } caCert, _ := x509.ParseCertificate(caDER) key, _ := rsa.GenerateKey(rand.Reader, 2048) leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: hosts[0]}, DNSNames: append(hosts, "localhost"), IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} der, err := x509.CreateCertificate(rand.Reader, leaf, caCert, &key.PublicKey, caKey) if err != nil { t.Fatal(err) } pk, _ := x509.MarshalPKCS8PrivateKey(key) return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pk}) } func TestFelisIssuedCert(t *testing.T) { mine, _, err := issuePanelCert(oldNames, nil, time.Now()) if err != nil { t.Fatal(err) } c, _ := x509.ParseCertificate(mustCertDER(mine)) if !felisIssuedCert(c) { t.Error("the installer's kind of certificate is not recognised as Felis-issued") } theirs, _ := caSignedCert(t, "console.old.example") c, _ = x509.ParseCertificate(mustCertDER(theirs)) if felisIssuedCert(c) { t.Error("a CA-signed certificate is taken for Felis-issued") } // Self-signed but without one of the installer's localhost names: someone // else's. key, _ := rsa.GenerateKey(rand.Reader, 2048) for name, self := range map[string]*x509.Certificate{ "no localhost": {DNSNames: []string{"console.old.example"}, IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}}, "no 127.0.0.1": {DNSNames: []string{"console.old.example", "localhost"}}, } { self.SerialNumber, self.Subject = big.NewInt(3), pkix.Name{CommonName: "x"} self.NotBefore, self.NotAfter = time.Now().Add(-time.Hour), time.Now().Add(time.Hour) der, _ := x509.CreateCertificate(rand.Reader, self, self, &key.PublicKey, key) c, _ = x509.ParseCertificate(der) if felisIssuedCert(c) { t.Errorf("%s: a self-signed certificate is taken for Felis-issued", name) } } } func TestIssuePanelCertIsTheInstallersShape(t *testing.T) { now := time.Now() certPEM, keyPEM, err := issuePanelCert(newNames, []net.IP{net.ParseIP("10.211.55.6"), net.IPv4(127, 0, 0, 1)}, now) if err != nil { t.Fatal(err) } if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil { t.Fatalf("key does not match the certificate: %v", err) } if b, _ := pem.Decode(keyPEM); b == nil || b.Type != "PRIVATE KEY" { t.Fatalf("key is not PKCS#8 PEM like openssl writes") } c, _ := x509.ParseCertificate(mustCertDER(certPEM)) if !certCovers(c, newNames.panel, newNames.admin, "localhost") || !felisIssuedCert(c) { t.Fatalf("names %v", c.DNSNames) } if len(c.IPAddresses) != 2 || !c.IPAddresses[1].Equal(net.ParseIP("10.211.55.6")) { t.Fatalf("addresses %v, want 127.0.0.1 and the node address once each", c.IPAddresses) } if c.Subject.CommonName != newNames.admin || c.NotAfter.Sub(now) < 824*24*time.Hour || c.NotAfter.Sub(now) > 826*24*time.Hour { t.Fatalf("CN %q, valid until %s", c.Subject.CommonName, c.NotAfter) } if len(c.ExtKeyUsage) != 1 || c.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth || c.IsCA { t.Fatalf("usage %v, CA %v", c.ExtKeyUsage, c.IsCA) } } func TestDomainSetMovesEverySurface(t *testing.T) { rig := newDomainRig(t) hostBefore := rig.read(t, "felis.host.toml") code, err := rig.h.set(context.Background(), "New.Example", true) if err != nil || code != 0 { t.Fatalf("set = %d, %v\n%s", code, err, rig.out) } // The configs: the three values moved, everything else — comments, the // database host of each copy, the Access audience — is as it was. host := rig.read(t, "felis.host.toml") if want := strings.NewReplacer("old.example", "new.example").Replace(hostBefore); host != want { t.Fatalf("host toml:\n%s", host) } pod := rig.read(t, "felis.pod.toml") if got, _ := tomlDomainNames([]byte(pod)); got != newNames || !strings.Contains(pod, "@10.211.55.6:5432") { t.Fatalf("pod toml:\n%s", pod) } if link, err := os.Readlink(rig.path("felis.toml")); err != nil || link != rig.path("felis.host.toml") { t.Fatalf("felis.toml is no longer the link to the host copy: %q %v", link, err) } if st, _ := os.Stat(rig.path("felis.host.toml")); st.Mode().Perm() != 0o600 { t.Fatalf("host toml mode %v", st.Mode().Perm()) } // The certificate: reissued for the new names, the node address kept, the old // pair beside it. c, err := readCertFile(rig.path("panel-tls.crt")) if err != nil || !certCovers(c, newNames.panel, newNames.admin) || !felisIssuedCert(c) { t.Fatalf("certificate: %v %v", err, c.DNSNames) } if !c.IPAddresses[len(c.IPAddresses)-1].Equal(net.ParseIP("10.211.55.6")) { t.Fatalf("addresses %v", c.IPAddresses) } if _, err := tls.LoadX509KeyPair(rig.path("panel-tls.crt"), rig.path("panel-tls.key")); err != nil { t.Fatalf("new pair: %v", err) } if st, _ := os.Stat(rig.path("panel-tls.key")); st.Mode().Perm() != 0o600 { t.Fatalf("key mode %v", st.Mode().Perm()) } backups, _ := filepath.Glob(rig.path("panel-tls.*.pre-domain-*")) if len(backups) != 2 { t.Fatalf("old pair kept as %v", backups) } for _, b := range backups { if st, _ := os.Stat(b); strings.Contains(b, ".key.") && st.Mode().Perm() != 0o600 { t.Fatalf("kept key %s has mode %v", b, st.Mode().Perm()) } old, _ := os.ReadFile(b) if strings.Contains(b, ".crt.") { oc, _ := x509.ParseCertificate(mustCertDER(old)) if oc == nil || !certCovers(oc, oldNames.panel) { t.Fatalf("kept certificate is not the old one") } } } // The Secrets carry the files. for _, ns := range []string{"felis", "minecraft"} { if got := rig.secret(t, ns, platform.ConfigSecretName)[platform.ConfigSecretKey]; string(got) != pod { t.Fatalf("%s/felis-config is not felis.pod.toml", ns) } } tlsData := rig.secret(t, "felis", platform.APITLSSecretName) if string(tlsData[corev1.TLSCertKey]) != rig.read(t, "panel-tls.crt") || string(tlsData[corev1.TLSPrivateKeyKey]) != rig.read(t, "panel-tls.key") { t.Fatal("felis-api-tls does not hold the new pair") } // The login gate's env moved and nothing else did. env := rig.crEnv(t, naming.SystemLoginServer) if env[envRootDomain] != newNames.root || env[envPanelHostname] != newNames.panel || env[envAPIBaseURL] != platform.InternalAPIBaseURL("felis") { t.Fatalf("login env %v", env) } // The proxy's file: the three keys moved, its token and mode did not. props := rig.read(t, "felis-link.properties") if want := strings.NewReplacer("old.example", "new.example").Replace(linkPropsBody); props != want { t.Fatalf("felis-link.properties:\n%s", props) } if st, _ := os.Stat(rig.path("felis-link.properties")); st.Mode().Perm() != 0o640 { t.Fatalf("felis-link.properties mode %v", st.Mode().Perm()) } if strings.Join(rig.events, ",") != "roll-api,restart felis-velocity" { t.Fatalf("events %v", rig.events) } if !strings.Contains(rig.out.String(), "Every surface is on new.example.") { t.Fatalf("the closing check did not pass:\n%s", rig.out) } } func TestDomainSetWithoutYesChangesNothing(t *testing.T) { rig := newDomainRig(t) before := rig.snapshot(t) code, err := rig.h.set(context.Background(), "new.example", false) if err != nil || code != 0 { t.Fatalf("set = %d, %v", code, err) } if rig.snapshot(t) != before || len(rig.events) != 0 { t.Fatalf("a dry run changed something (events %v)", rig.events) } out := rig.out.String() for _, want := range []string{ "console.old.example → console.new.example", "3 passkey(s) of 2 user(s) are bound to console.old.example", "does not cover op.console.new.example", "No [smtp] relay is configured", "sudo felis domain set -yes new.example", } { if !strings.Contains(out, want) { t.Errorf("plan lacks %q:\n%s", want, out) } } } func TestDomainSetKeepsAHandSetPanelHostname(t *testing.T) { rig := newDomainRig(t) for _, f := range []string{"felis.host.toml", "felis.pod.toml"} { writeTestFile(t, rig.path(f), strings.Replace(rig.read(t, f), `panel_hostname = "console.old.example"`, `panel_hostname = "play.corp.net"`, 1), 0o600) } code, err := rig.h.set(context.Background(), "new.example", true) if err != nil { t.Fatalf("set: %v\n%s", err, rig.out) } got, _ := tomlDomainNames([]byte(rig.read(t, "felis.host.toml"))) if got != (domainNames{root: "new.example", panel: "play.corp.net", admin: "op.console.new.example"}) { t.Fatalf("names %+v", got) } c, _ := readCertFile(rig.path("panel-tls.crt")) if !certCovers(c, "play.corp.net", "op.console.new.example") { t.Fatalf("certificate names %v", c.DNSNames) } if env := rig.crEnv(t, naming.SystemLoginServer); env[envPanelHostname] != "play.corp.net" { t.Fatalf("login env %v", env) } if code != 0 || !strings.Contains(rig.out.String(), "play.corp.net (set by hand, kept") { t.Fatalf("code %d:\n%s", code, rig.out) } } func TestDomainSetRefusesAnOperatorCertificateForOtherNames(t *testing.T) { rig := newDomainRig(t) certPEM, keyPEM := caSignedCert(t, "console.old.example", "op.console.old.example") writeTestFile(t, rig.path("panel-tls.crt"), string(certPEM), 0o644) writeTestFile(t, rig.path("panel-tls.key"), string(keyPEM), 0o600) before := rig.snapshot(t) if _, err := rig.h.set(context.Background(), "new.example", true); err == nil || !strings.Contains(err.Error(), "not issued by Felis") { t.Fatalf("err = %v", err) } if rig.snapshot(t) != before || len(rig.events) != 0 { t.Fatal("a refused move changed something") } // The operator's certificate for the new names is kept as it is. certPEM, keyPEM = caSignedCert(t, "console.new.example", "op.console.new.example") writeTestFile(t, rig.path("panel-tls.crt"), string(certPEM), 0o644) writeTestFile(t, rig.path("panel-tls.key"), string(keyPEM), 0o600) if _, err := rig.h.set(context.Background(), "new.example", true); err != nil { t.Fatalf("set: %v", err) } if rig.read(t, "panel-tls.crt") != string(certPEM) { t.Fatal("the operator's certificate was replaced") } } func TestDomainSetRefusesAConfigItCannotEdit(t *testing.T) { rig := newDomainRig(t) writeTestFile(t, rig.path("felis.pod.toml"), strings.Replace(rig.read(t, "felis.pod.toml"), "[auth]", "[\"auth\"]", 1), 0o600) before := rig.snapshot(t) if _, err := rig.h.set(context.Background(), "new.example", true); err == nil || !strings.Contains(err.Error(), "by hand") { t.Fatalf("err = %v", err) } if rig.snapshot(t) != before || len(rig.events) != 0 { t.Fatal("a refused move changed something") } } func TestDomainSetAgainOnlyConvergesWhatIsBehind(t *testing.T) { rig := newDomainRig(t) if _, err := rig.h.set(context.Background(), "new.example", true); err != nil { t.Fatal(err) } rig.events, rig.out = nil, &bytes.Buffer{} rig.h.out = rig.out before := rig.snapshot(t) code, err := rig.h.set(context.Background(), "new.example", true) if err != nil || code != 0 { t.Fatalf("second set = %d, %v\n%s", code, err, rig.out) } if len(rig.events) != 0 || rig.snapshot(t) != before { t.Fatalf("a converged install was touched again: %v\n%s", rig.events, rig.out) } // A proxy that was not restarted after the move is restarted by a re-run, and // an api still on the old config is rolled. rig.proxySince = time.Now().Add(-time.Hour) rig.served = oldNames if _, err := rig.h.set(context.Background(), "new.example", true); err != nil { t.Fatal(err) } if strings.Join(rig.events, ",") != "roll-api,restart felis-velocity" { t.Fatalf("events %v", rig.events) } // An api on the new names that still presents the old certificate is rolled. rig.events = nil oldCert, _, _ := issuePanelCert(oldNames, nil, time.Now()) rig.servedCert, _ = x509.ParseCertificate(mustCertDER(oldCert)) if _, err := rig.h.set(context.Background(), "new.example", true); err != nil { t.Fatal(err) } if strings.Join(rig.events, ",") != "roll-api" { t.Fatalf("events %v", rig.events) } } func TestDomainSetRefusesALoginServerItDoesNotOwn(t *testing.T) { rig := newDomainRig(t) var ms v1alpha1.MinecraftServer if err := rig.cl.Get(context.Background(), client.ObjectKey{Namespace: "minecraft", Name: naming.SystemLoginServer}, &ms); err != nil { t.Fatal(err) } delete(ms.Labels, v1alpha1.LabelSystemRole) if err := rig.cl.Update(context.Background(), &ms); err != nil { t.Fatal(err) } if _, err := rig.h.set(context.Background(), "new.example", true); err == nil || !strings.Contains(err.Error(), "system role") { t.Fatalf("err = %v", err) } if env := rig.crEnv(t, naming.SystemLoginServer); env[envRootDomain] != oldNames.root { t.Fatalf("a server not marked as the login gate was changed: %v", env) } } func TestDomainCheckNamesTheSurfaceThatIsBehind(t *testing.T) { cases := []struct { surface string breakIt func(t *testing.T, rig *domainRig) }{ {"felis.pod.toml", func(t *testing.T, rig *domainRig) { writeTestFile(t, rig.path("felis.pod.toml"), installerTOML("old.example", "10.211.55.6"), 0o600) }}, {"Secret minecraft/felis-config", func(t *testing.T, rig *domainRig) { rig.putSecret(t, "minecraft", platform.ConfigSecretName, platform.ConfigSecretKey, installerTOML("old.example", "x")) }}, {"Secret felis/felis-config", func(t *testing.T, rig *domainRig) { rig.putSecret(t, "felis", platform.ConfigSecretName, platform.ConfigSecretKey, installerTOML("old.example", "x")) }}, {"panel certificate", func(t *testing.T, rig *domainRig) { // The Secret follows the file, so only the certificate's names are wrong. certPEM, _, _ := issuePanelCert(oldNames, nil, time.Now()) writeTestFile(t, rig.path("panel-tls.crt"), string(certPEM), 0o644) rig.putSecret(t, "felis", platform.APITLSSecretName, corev1.TLSCertKey, string(certPEM)) }}, {"Secret felis/felis-api-tls", func(t *testing.T, rig *domainRig) { certPEM, _, _ := issuePanelCert(newNames, nil, time.Now()) rig.putSecret(t, "felis", platform.APITLSSecretName, corev1.TLSCertKey, string(certPEM)) }}, {"felis-api", func(t *testing.T, rig *domainRig) { rig.served = oldNames }}, {"felis-api", func(t *testing.T, rig *domainRig) { certPEM, _, _ := issuePanelCert(oldNames, nil, time.Now()) rig.servedCert, _ = x509.ParseCertificate(mustCertDER(certPEM)) }}, {"proxy", func(t *testing.T, rig *domainRig) { writeTestFile(t, rig.path("felis-link.properties"), linkPropsBody, 0o640) rig.proxySince = time.Now().Add(time.Hour) }}, {"proxy", func(t *testing.T, rig *domainRig) { rig.proxySince = time.Now().Add(-time.Hour) }}, {"login gate", func(t *testing.T, rig *domainRig) { var ms v1alpha1.MinecraftServer _ = rig.cl.Get(context.Background(), client.ObjectKey{Namespace: "minecraft", Name: naming.SystemLoginServer}, &ms) for i := range ms.Spec.Env { if ms.Spec.Env[i].Name == envRootDomain { ms.Spec.Env[i].Value = "old.example" } } if err := rig.cl.Update(context.Background(), &ms); err != nil { t.Fatal(err) } }}, {"login gate", func(t *testing.T, rig *domainRig) { rig.setPodEnv(t, envRootDomain, "old.example") }}, {"login gate", func(t *testing.T, rig *domainRig) { rig.setPodEnv(t, envPanelHostname, "console.old.example") }}, {"Cloudflare tunnel", func(t *testing.T, rig *domainRig) { writeTestFile(t, rig.path("cloudflared.yml"), "tunnel: abc\ningress:\n- hostname: console.new.example\n service: https://127.0.0.1:30443\n- hostname: op.console.old.example\n service: https://127.0.0.1:30443\n- service: http_status:404\n", 0o644) }}, } for _, tc := range cases { rig := newDomainRig(t) if _, err := rig.h.set(context.Background(), "new.example", true); err != nil { t.Fatal(err) } rig.out.Reset() tc.breakIt(t, rig) if code := rig.h.check(context.Background()); code != 1 { t.Errorf("%s behind: check = %d\n%s", tc.surface, code, rig.out) continue } var failed []string for _, ln := range strings.Split(rig.out.String(), "\n") { if strings.HasPrefix(ln, " FAIL ") { failed = append(failed, ln) } } if len(failed) != 1 || !strings.Contains(failed[0], tc.surface) { t.Errorf("%s behind: FAIL lines %q", tc.surface, failed) } } } func TestDomainCheckPassesAConvergedInstallAndWarnsOnDNS(t *testing.T) { rig := newDomainRig(t) if _, err := rig.h.set(context.Background(), "new.example", true); err != nil { t.Fatal(err) } writeTestFile(t, rig.path("cloudflared.yml"), "tunnel: abc\ningress:\n- hostname: console.new.example\n service: https://127.0.0.1:30443\n- hostname: op.console.new.example\n service: https://127.0.0.1:30443\n- service: http_status:404\n", 0o644) rig.unresolved["op.console.new.example"] = true rig.unresolved[dnsProbeLabel+".new.example"] = true rig.out.Reset() if code := rig.h.check(context.Background()); code != 0 { t.Fatalf("check = %d\n%s", code, rig.out) } out := rig.out.String() for _, want := range []string{" ok Cloudflare tunnel: routes both names", " warn DNS: op.console.new.example, *.new.example do not resolve from this host\n"} { if !strings.Contains(out, want) { t.Errorf("check lacks %q:\n%s", want, out) } } if strings.Contains(out, "TOKEN-NOT-TO-TOUCH") { t.Fatal("check printed the proxy's service token") } // A zone with only the wildcard: the admin name alone is missing, and why is said. delete(rig.unresolved, dnsProbeLabel+".new.example") rig.out.Reset() rig.h.check(context.Background()) if want := " warn DNS: op.console.new.example does not resolve from this host: the *.new.example wildcard does not cover op.console.new.example, which needs its own record\n"; !strings.Contains(rig.out.String(), want) { t.Errorf("check lacks %q:\n%s", want, rig.out) } } func (rig *domainRig) setPodEnv(t *testing.T, name, value string) { t.Helper() var pod corev1.Pod if err := rig.cl.Get(context.Background(), client.ObjectKey{Namespace: "minecraft", Name: "login-0"}, &pod); err != nil { t.Fatal(err) } for i, e := range pod.Spec.Containers[0].Env { if e.Name == name { pod.Spec.Containers[0].Env[i].Value = value } } if err := rig.cl.Update(context.Background(), &pod); err != nil { t.Fatal(err) } } func (rig *domainRig) putSecret(t *testing.T, ns, name, key, val string) { t.Helper() var s corev1.Secret if err := rig.cl.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: name}, &s); err != nil { t.Fatal(err) } s.Data[key] = []byte(val) if err := rig.cl.Update(context.Background(), &s); err != nil { t.Fatal(err) } } func TestParseUnitShow(t *testing.T) { st := parseUnitShow("LoadState=loaded\nActiveState=active\nActiveEnterTimestamp=@1790000000\n") if !st.loaded || !st.active || !st.since.Equal(time.Unix(1790000000, 0)) { t.Fatalf("%+v", st) } st = parseUnitShow("LoadState=not-found\nActiveState=inactive\nActiveEnterTimestamp=\n") if st.loaded || st.active || !st.since.IsZero() { t.Fatalf("%+v", st) } }