diff --git a/cmd/osiris/internal/config/domain.go b/cmd/osiris/internal/config/domain.go index 1d7336b2..1587a652 100644 --- a/cmd/osiris/internal/config/domain.go +++ b/cmd/osiris/internal/config/domain.go @@ -16,10 +16,11 @@ var ( ) type Domain struct { - Name string `hcl:"name,label"` - TLS TLS `hcl:"tls,block"` - Target string `hcl:"target"` - HealthTarget string `hcl:"health_target"` + Name string `hcl:"name,label"` + TLS TLS `hcl:"tls,block"` + Target string `hcl:"target"` + InsecureSkipVerify bool `hcl:"insecure_skip_verify,optional"` + HealthTarget string `hcl:"health_target"` } func (d Domain) Valid() error { diff --git a/cmd/osiris/internal/entrypoint/entrypoint.go b/cmd/osiris/internal/entrypoint/entrypoint.go index 73d696ea..a64779f9 100644 --- a/cmd/osiris/internal/entrypoint/entrypoint.go +++ b/cmd/osiris/internal/entrypoint/entrypoint.go @@ -2,20 +2,13 @@ package entrypoint import ( "context" - "crypto/tls" "fmt" "log/slog" "net" - "net/http" - "os" - "os/signal" - "syscall" "github.com/TecharoHQ/anubis/cmd/osiris/internal/config" "github.com/TecharoHQ/anubis/internal" - "github.com/TecharoHQ/anubis/internal/fingerprint" "github.com/hashicorp/hcl/v2/hclsimple" - "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/sync/errgroup" healthv1 "google.golang.org/grpc/health/grpc_health_v1" ) @@ -24,12 +17,9 @@ type Options struct { ConfigFname string } -func Main(opts Options) error { +func Main(ctx context.Context, opts Options) error { internal.SetHealth("osiris", healthv1.HealthCheckResponse_NOT_SERVING) - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer cancel() - var cfg config.Toplevel if err := hclsimple.DecodeFile(opts.ConfigFname, nil, &cfg); err != nil { return fmt.Errorf("can't read configuration file %s:\n\n%w", opts.ConfigFname, err) @@ -57,13 +47,11 @@ func Main(opts Options) error { go func(ctx context.Context) { <-ctx.Done() ln.Close() - }(gCtx) + }(ctx) slog.Info("listening", "for", "http", "bind", cfg.Bind.HTTP) - srv := http.Server{Handler: rtr, ErrorLog: internal.GetFilteredHTTPLogger()} - - return srv.Serve(ln) + return rtr.HandleHTTP(gCtx, ln) }) // HTTPS @@ -77,70 +65,16 @@ func Main(opts Options) error { go func(ctx context.Context) { <-ctx.Done() ln.Close() - }(gCtx) - - tc := &tls.Config{ - GetCertificate: rtr.GetCertificate, - } - - srv := &http.Server{ - Addr: cfg.Bind.HTTPS, - Handler: rtr, - ErrorLog: internal.GetFilteredHTTPLogger(), - TLSConfig: tc, - } - - fingerprint.ApplyTLSFingerprinter(srv) + }(ctx) slog.Info("listening", "for", "https", "bind", cfg.Bind.HTTPS) - return srv.ServeTLS(ln, "", "") + return rtr.HandleHTTPS(gCtx, ln) }) // Metrics g.Go(func() error { - ln, err := net.Listen("tcp", cfg.Bind.Metrics) - if err != nil { - return fmt.Errorf("(metrics) can't bind to tcp %s: %w", cfg.Bind.Metrics, err) - } - defer ln.Close() - - go func(ctx context.Context) { - <-ctx.Done() - ln.Close() - }(gCtx) - - mux := http.NewServeMux() - - mux.Handle("/metrics", promhttp.Handler()) - mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - st, ok := internal.GetHealth("osiris") - if !ok { - slog.Error("health service osiris does not exist, file a bug") - } - - switch st { - case healthv1.HealthCheckResponse_NOT_SERVING: - http.Error(w, "NOT OK", http.StatusInternalServerError) - return - case healthv1.HealthCheckResponse_SERVING: - fmt.Fprintln(w, "OK") - return - default: - http.Error(w, "UNKNOWN", http.StatusFailedDependency) - return - } - }) - - slog.Info("listening", "for", "metrics", "bind", cfg.Bind.Metrics) - - srv := http.Server{ - Addr: cfg.Bind.Metrics, - Handler: mux, - ErrorLog: internal.GetFilteredHTTPLogger(), - } - - return srv.Serve(ln) + return rtr.ListenAndServeMetrics(gCtx, cfg.Bind.Metrics) }) internal.SetHealth("osiris", healthv1.HealthCheckResponse_SERVING) diff --git a/cmd/osiris/internal/entrypoint/entrypoint_test.go b/cmd/osiris/internal/entrypoint/entrypoint_test.go new file mode 100644 index 00000000..5dfd03b1 --- /dev/null +++ b/cmd/osiris/internal/entrypoint/entrypoint_test.go @@ -0,0 +1,93 @@ +package entrypoint + +import ( + "context" + "errors" + "net" + "net/http" + "os" + "path/filepath" + "testing" + "time" +) + +func TestMainGoodConfig(t *testing.T) { + files, err := os.ReadDir("./testdata/good") + if err != nil { + t.Fatal(err) + } + + for _, st := range files { + t.Run(st.Name(), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cfg := loadConfig(t, filepath.Join("testdata", "good", st.Name())) + + go func(ctx context.Context) { + if err := Main(ctx, Options{ + ConfigFname: filepath.Join("testdata", "good", st.Name()), + }); err != nil { + var netOpErr *net.OpError + switch { + case errors.Is(err, context.Canceled): + // Context was canceled, this is expected + return + case errors.As(err, &netOpErr): + // Network operation error occurred + t.Logf("Network operation error: %v", netOpErr) + return + case errors.Is(err, http.ErrServerClosed): + // Server was closed, this is expected + return + default: + // Other unexpected error + panic(err) + } + } + }(ctx) + + wait := 5 * time.Millisecond + + for i := range make([]struct{}, 10) { + if i != 0 { + time.Sleep(wait) + wait = wait * 2 + } + + t.Logf("try %d (wait=%s)", i+1, wait) + + resp, err := http.Get("http://localhost" + cfg.Bind.Metrics + "/readyz") + if err != nil { + continue + } + + if resp.StatusCode != http.StatusOK { + continue + } + + cancel() + return + } + + t.Fatal("router initialization did not work") + }) + } +} + +func TestMainBadConfig(t *testing.T) { + files, err := os.ReadDir("./testdata/bad") + if err != nil { + t.Fatal(err) + } + + for _, st := range files { + t.Run(st.Name(), func(t *testing.T) { + if err := Main(t.Context(), Options{ + ConfigFname: filepath.Join("testdata", "bad", st.Name()), + }); err == nil { + t.Error("wanted an error but got none") + } else { + t.Log(err) + } + }) + } +} diff --git a/cmd/osiris/internal/entrypoint/h2c.go b/cmd/osiris/internal/entrypoint/h2c.go index 68c87512..d0e6f35e 100644 --- a/cmd/osiris/internal/entrypoint/h2c.go +++ b/cmd/osiris/internal/entrypoint/h2c.go @@ -11,6 +11,8 @@ import ( ) func newH2CReverseProxy(target *url.URL) *httputil.ReverseProxy { + target.Scheme = "http" + director := func(req *http.Request) { req.URL.Scheme = target.Scheme req.URL.Host = target.Host diff --git a/cmd/osiris/internal/entrypoint/h2c_test.go b/cmd/osiris/internal/entrypoint/h2c_test.go new file mode 100644 index 00000000..af64c914 --- /dev/null +++ b/cmd/osiris/internal/entrypoint/h2c_test.go @@ -0,0 +1,51 @@ +package entrypoint + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +func newH2cServer(t *testing.T, h http.Handler) *httptest.Server { + t.Helper() + + h2s := &http2.Server{} + + srv := httptest.NewServer(h2c.NewHandler(h, h2s)) + t.Cleanup(func() { + srv.Close() + }) + + return srv +} + +func TestH2CReverseProxy(t *testing.T) { + h := &ackHandler{} + + srv := newH2cServer(t, h) + + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatal(err) + } + + rp := httptest.NewServer(newH2CReverseProxy(u)) + defer rp.Close() + + resp, err := rp.Client().Get(rp.URL) + if err != nil { + t.Fatal(err) + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("wrong status code from reverse proxy: %d", resp.StatusCode) + } + + if !h.ack { + t.Error("h2c handler was not executed") + } +} diff --git a/cmd/osiris/internal/entrypoint/metrics.go b/cmd/osiris/internal/entrypoint/metrics.go new file mode 100644 index 00000000..7044be75 --- /dev/null +++ b/cmd/osiris/internal/entrypoint/metrics.go @@ -0,0 +1,72 @@ +package entrypoint + +import ( + "bytes" + "fmt" + "log/slog" + "net/http" + "sort" + + "github.com/TecharoHQ/anubis/internal" + healthv1 "google.golang.org/grpc/health/grpc_health_v1" +) + +func healthz(w http.ResponseWriter, r *http.Request) { + services, err := internal.HealthSrv.List(r.Context(), nil) + if err != nil { + slog.Error("can't get list of services", "err", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var keys []string + for k := range services.Statuses { + if k == "" { + continue + } + keys = append(keys, k) + } + + sort.Strings(keys) + + var msg bytes.Buffer + + var healthy bool = true + + for _, k := range keys { + st := services.Statuses[k].GetStatus() + fmt.Fprintf(&msg, "%s: %s\n", k, st) + switch st { + case healthv1.HealthCheckResponse_SERVING: + // do nothing + default: + healthy = false + } + } + + if !healthy { + w.WriteHeader(http.StatusInternalServerError) + } + + w.Write(msg.Bytes()) +} + +func readyz(w http.ResponseWriter, r *http.Request) { + st, ok := internal.GetHealth("osiris") + if !ok { + slog.Error("health service osiris does not exist, file a bug") + http.Error(w, "health service osiris does not exist", http.StatusExpectationFailed) + } + + switch st { + case healthv1.HealthCheckResponse_NOT_SERVING: + http.Error(w, "NOT OK", http.StatusInternalServerError) + return + case healthv1.HealthCheckResponse_SERVING: + fmt.Fprintln(w, "OK") + return + default: + http.Error(w, "UNKNOWN", http.StatusFailedDependency) + return + } +} diff --git a/cmd/osiris/internal/entrypoint/metrics_test.go b/cmd/osiris/internal/entrypoint/metrics_test.go new file mode 100644 index 00000000..0967eb1e --- /dev/null +++ b/cmd/osiris/internal/entrypoint/metrics_test.go @@ -0,0 +1,66 @@ +package entrypoint + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/TecharoHQ/anubis/internal" + healthv1 "google.golang.org/grpc/health/grpc_health_v1" +) + +func TestHealthz(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(healthz)) + + internal.SetHealth("osiris", healthv1.HealthCheckResponse_NOT_SERVING) + + resp, err := srv.Client().Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + t.Errorf("wanted not ready but got %d", resp.StatusCode) + } + + internal.SetHealth("osiris", healthv1.HealthCheckResponse_SERVING) + + resp, err = srv.Client().Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("wanted ready but got %d", resp.StatusCode) + } +} + +func TestReadyz(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(readyz)) + + internal.SetHealth("osiris", healthv1.HealthCheckResponse_NOT_SERVING) + + resp, err := srv.Client().Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + t.Errorf("wanted not ready but got %d", resp.StatusCode) + } + + internal.SetHealth("osiris", healthv1.HealthCheckResponse_SERVING) + + resp, err = srv.Client().Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("wanted ready but got %d", resp.StatusCode) + } +} diff --git a/cmd/osiris/internal/entrypoint/router.go b/cmd/osiris/internal/entrypoint/router.go index 0f720ad1..72f05822 100644 --- a/cmd/osiris/internal/entrypoint/router.go +++ b/cmd/osiris/internal/entrypoint/router.go @@ -14,10 +14,13 @@ import ( "sync" "github.com/TecharoHQ/anubis/cmd/osiris/internal/config" + "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal/fingerprint" + "github.com/felixge/httpsnoop" "github.com/lum8rjack/go-ja4h" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" ) var ( @@ -30,6 +33,12 @@ var ( Namespace: "techaro", Subsystem: "osiris", Name: "request_count", + }, []string{"domain", "method", "response_code"}) + + responseTime = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "techaro", + Subsystem: "osiris", + Name: "response_time", }, []string{"domain"}) unresolvedRequests = promauto.NewGauge(prometheus.GaugeOpts{ @@ -60,18 +69,35 @@ func (rtr *Router) setConfig(c config.Toplevel) error { var h http.Handler - switch u.Scheme { - case "http", "https": - h = httputil.NewSingleHostReverseProxy(u) - case "h2c": - h = newH2CReverseProxy(u) - case "unix": - h = &httputil.ReverseProxy{ - Transport: &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", strings.TrimPrefix(d.Target, "unix://")) + if u != nil { + switch u.Scheme { + case "http", "https": + rp := httputil.NewSingleHostReverseProxy(u) + + if d.InsecureSkipVerify { + rp.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + } + + h = rp + case "h2c": + h = newH2CReverseProxy(u) + case "unix": + h = &httputil.ReverseProxy{ + Director: func(r *http.Request) { + r.URL.Scheme = "http" + r.URL.Host = d.Name + r.Host = d.Name }, - }, + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", strings.TrimPrefix(d.Target, "unix://")) + }, + }, + } } } @@ -129,6 +155,75 @@ func NewRouter(c config.Toplevel) (*Router, error) { return result, nil } +func (rtr *Router) HandleHTTP(ctx context.Context, ln net.Listener) error { + srv := http.Server{ + Handler: rtr, + ErrorLog: internal.GetFilteredHTTPLogger(), + } + + go func(ctx context.Context) { + <-ctx.Done() + srv.Close() + }(ctx) + + return srv.Serve(ln) +} + +func (rtr *Router) HandleHTTPS(ctx context.Context, ln net.Listener) error { + tc := &tls.Config{ + GetCertificate: rtr.GetCertificate, + } + + srv := &http.Server{ + Handler: rtr, + ErrorLog: internal.GetFilteredHTTPLogger(), + TLSConfig: tc, + } + + go func(ctx context.Context) { + <-ctx.Done() + srv.Close() + }(ctx) + + fingerprint.ApplyTLSFingerprinter(srv) + + return srv.ServeTLS(ln, "", "") +} + +func (rtr *Router) ListenAndServeMetrics(ctx context.Context, addr string) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("(metrics) can't bind to tcp %s: %w", addr, err) + } + defer ln.Close() + + go func(ctx context.Context) { + <-ctx.Done() + ln.Close() + }(ctx) + + mux := http.NewServeMux() + + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/readyz", readyz) + mux.HandleFunc("/healthz", healthz) + + slog.Info("listening", "for", "metrics", "bind", addr) + + srv := http.Server{ + Addr: addr, + Handler: mux, + ErrorLog: internal.GetFilteredHTTPLogger(), + } + + go func(ctx context.Context) { + <-ctx.Done() + srv.Close() + }(ctx) + + return srv.Serve(ln) +} + func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { var host = r.Host @@ -136,8 +231,6 @@ func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { host, _, _ = net.SplitHostPort(host) } - requestsPerDomain.WithLabelValues(host).Inc() - var h http.Handler var ok bool @@ -170,5 +263,10 @@ func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { r.Header.Set("X-Tcp-Ja4t-Fingerprint", tcpFP.String()) } - h.ServeHTTP(w, r) + m := httpsnoop.CaptureMetrics(h, w, r) + + requestsPerDomain.WithLabelValues(host, r.Method, fmt.Sprint(m.Code)).Inc() + responseTime.WithLabelValues(host).Observe(float64(m.Duration.Milliseconds())) + + slog.Info("request completed", "host", host, "method", r.Method, "response_code", m.Code, "duration_ms", m.Duration.Milliseconds()) } diff --git a/cmd/osiris/internal/entrypoint/router_test.go b/cmd/osiris/internal/entrypoint/router_test.go new file mode 100644 index 00000000..677a177d --- /dev/null +++ b/cmd/osiris/internal/entrypoint/router_test.go @@ -0,0 +1,319 @@ +package entrypoint + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/TecharoHQ/anubis/cmd/osiris/internal/config" + "github.com/hashicorp/hcl/v2/hclsimple" +) + +func loadConfig(t *testing.T, fname string) config.Toplevel { + t.Helper() + + var cfg config.Toplevel + if err := hclsimple.DecodeFile(fname, nil, &cfg); err != nil { + t.Fatalf("can't read configuration file %s: %v", fname, err) + } + + if err := cfg.Valid(); err != nil { + t.Errorf("configuration file %s is invalid: %v", "./testdata/selfsigned.hcl", err) + } + + return cfg +} + +func newRouter(t *testing.T, cfg config.Toplevel) *Router { + t.Helper() + + rtr, err := NewRouter(cfg) + if err != nil { + t.Fatal(err) + } + + return rtr +} + +func TestNewRouter(t *testing.T) { + cfg := loadConfig(t, "./testdata/good/selfsigned.hcl") + rtr := newRouter(t, cfg) + + srv := httptest.NewServer(rtr) + defer srv.Close() +} + +func TestNewRouterFails(t *testing.T) { + cfg := loadConfig(t, "./testdata/good/selfsigned.hcl") + + cfg.Domains = append(cfg.Domains, config.Domain{ + Name: "test1.internal", + TLS: config.TLS{ + Cert: "./testdata/tls/invalid.crt", + Key: "./testdata/tls/invalid.key", + }, + Target: cfg.Domains[0].Target, + HealthTarget: cfg.Domains[0].HealthTarget, + }) + + rtr, err := NewRouter(cfg) + if err == nil { + t.Fatal("wanted an error but got none") + } + + srv := httptest.NewServer(rtr) + defer srv.Close() +} + +func TestRouterSetConfig(t *testing.T) { + for _, tt := range []struct { + name string + configFname string + mutation func(cfg config.Toplevel) config.Toplevel + err error + }{ + { + name: "basic", + configFname: "./testdata/good/selfsigned.hcl", + mutation: func(cfg config.Toplevel) config.Toplevel { + return cfg + }, + }, + { + name: "all schemes", + configFname: "./testdata/good/selfsigned.hcl", + mutation: func(cfg config.Toplevel) config.Toplevel { + cfg.Domains = append(cfg.Domains, config.Domain{ + Name: "http.internal", + TLS: cfg.Domains[0].TLS, + Target: "http://[::1]:3000", + HealthTarget: cfg.Domains[0].HealthTarget, + }) + cfg.Domains = append(cfg.Domains, config.Domain{ + Name: "https.internal", + TLS: cfg.Domains[0].TLS, + Target: "https://[::1]:3000", + HealthTarget: cfg.Domains[0].HealthTarget, + }) + cfg.Domains = append(cfg.Domains, config.Domain{ + Name: "h2c.internal", + TLS: cfg.Domains[0].TLS, + Target: "h2c://[::1]:3000", + HealthTarget: cfg.Domains[0].HealthTarget, + }) + cfg.Domains = append(cfg.Domains, config.Domain{ + Name: "unix.internal", + TLS: cfg.Domains[0].TLS, + Target: "unix://foo.sock", + HealthTarget: cfg.Domains[0].HealthTarget, + }) + + return cfg + }, + }, + { + name: "invalid TLS", + configFname: "./testdata/good/selfsigned.hcl", + mutation: func(cfg config.Toplevel) config.Toplevel { + cfg.Domains = append(cfg.Domains, config.Domain{ + Name: "test1.internal", + TLS: config.TLS{ + Cert: "./testdata/tls/invalid.crt", + Key: "./testdata/tls/invalid.key", + }, + Target: cfg.Domains[0].Target, + HealthTarget: cfg.Domains[0].HealthTarget, + }) + + return cfg + }, + err: ErrInvalidTLSKeypair, + }, + { + name: "target is not a valid URL", + configFname: "./testdata/good/selfsigned.hcl", + mutation: func(cfg config.Toplevel) config.Toplevel { + cfg.Domains = append(cfg.Domains, config.Domain{ + Name: "test1.internal", + TLS: cfg.Domains[0].TLS, + Target: "http://[::1:443", + HealthTarget: cfg.Domains[0].HealthTarget, + }) + + return cfg + }, + err: ErrTargetInvalid, + }, + { + name: "invalid target scheme", + configFname: "./testdata/good/selfsigned.hcl", + mutation: func(cfg config.Toplevel) config.Toplevel { + cfg.Domains = append(cfg.Domains, config.Domain{ + Name: "test1.internal", + TLS: cfg.Domains[0].TLS, + Target: "foo://", + HealthTarget: cfg.Domains[0].HealthTarget, + }) + + return cfg + }, + err: ErrNoHandler, + }, + } { + t.Run(tt.name, func(t *testing.T) { + cfg := loadConfig(t, tt.configFname) + rtr := newRouter(t, cfg) + + cfg = tt.mutation(cfg) + + if err := rtr.setConfig(cfg); !errors.Is(err, tt.err) { + t.Logf("want: %v", tt.err) + t.Logf("got: %v", err) + t.Error("got wrong error from rtr.setConfig function") + } + }) + } +} + +type ackHandler struct { + ack bool +} + +func (ah *ackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ah.ack = true + fmt.Fprintln(w, "OK") +} + +func (ah *ackHandler) Reset() { + ah.ack = false +} + +func newUnixServer(t *testing.T, h http.Handler) string { + sockName := filepath.Join(t.TempDir(), "s") + ln, err := net.Listen("unix", sockName) + if err != nil { + t.Fatalf("can't listen on %s: %v", sockName, err) + } + t.Cleanup(func() { + ln.Close() + os.Remove(sockName) + }) + + go func(ctx context.Context) { + srv := &http.Server{ + Handler: h, + } + + go func() { + <-ctx.Done() + srv.Close() + }() + + srv.Serve(ln) + }(t.Context()) + + return "unix://" + sockName +} + +func TestRouterGetCertificate(t *testing.T) { + cfg := loadConfig(t, "./testdata/good/selfsigned.hcl") + rtr := newRouter(t, cfg) + + for _, tt := range []struct { + domainName string + err error + }{ + { + domainName: "osiris.local.cetacean.club", + }, + { + domainName: "whacky-fun.local", + err: ErrNoCert, + }, + } { + t.Run(tt.domainName, func(t *testing.T) { + if _, err := rtr.GetCertificate(&tls.ClientHelloInfo{ServerName: tt.domainName}); !errors.Is(err, tt.err) { + t.Logf("want: %v", tt.err) + t.Logf("got: %v", err) + t.Error("got wrong error from rtr.GetCertificate") + } + }) + } +} + +func TestRouterServeAllProtocols(t *testing.T) { + cfg := loadConfig(t, "./testdata/good/all_protocols.hcl") + + httpAckHandler := &ackHandler{} + httpsAckHandler := &ackHandler{} + h2cAckHandler := &ackHandler{} + unixAckHandler := &ackHandler{} + + httpSrv := httptest.NewServer(httpAckHandler) + httpsSrv := httptest.NewTLSServer(httpsAckHandler) + h2cSrv := newH2cServer(t, h2cAckHandler) + unixPath := newUnixServer(t, unixAckHandler) + + cfg.Domains[0].Target = httpSrv.URL + cfg.Domains[1].Target = httpsSrv.URL + cfg.Domains[2].Target = strings.ReplaceAll(h2cSrv.URL, "http:", "h2c:") + cfg.Domains[3].Target = unixPath + + // enc := json.NewEncoder(os.Stderr) + // enc.SetIndent("", " ") + // enc.Encode(cfg) + + rtr := newRouter(t, cfg) + + cli := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + + t.Run("plain http", func(t *testing.T) { + ln, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ln.Close() + }) + + go rtr.HandleHTTP(t.Context(), ln) + + serverURL := "http://" + ln.Addr().String() + t.Log(serverURL) + + for _, d := range cfg.Domains { + t.Run(d.Name, func(t *testing.T) { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, serverURL, nil) + if err != nil { + t.Fatal(err) + } + + req.Host = d.Name + + resp, err := cli.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("wrong status code %d", resp.StatusCode) + } + }) + } + }) +} diff --git a/cmd/osiris/internal/entrypoint/testdata/bad/empty.hcl b/cmd/osiris/internal/entrypoint/testdata/bad/empty.hcl new file mode 100644 index 00000000..e69de29b diff --git a/cmd/osiris/internal/entrypoint/testdata/bad/invalid.hcl b/cmd/osiris/internal/entrypoint/testdata/bad/invalid.hcl new file mode 100644 index 00000000..3b0321e9 --- /dev/null +++ b/cmd/osiris/internal/entrypoint/testdata/bad/invalid.hcl @@ -0,0 +1,15 @@ +bind { + http = ":65530" + https = ":65531" + metrics = ":65532" +} + +domain "osiris.local.cetacean.club" { + tls { + cert = "./testdata/invalid.crt" + key = "./testdata/invalid.key" + } + + target = "http://localhost:3000" + health_target = "http://localhost:9091/healthz" +} \ No newline at end of file diff --git a/cmd/osiris/internal/entrypoint/testdata/good/all_protocols.hcl b/cmd/osiris/internal/entrypoint/testdata/good/all_protocols.hcl new file mode 100644 index 00000000..4df66d99 --- /dev/null +++ b/cmd/osiris/internal/entrypoint/testdata/good/all_protocols.hcl @@ -0,0 +1,46 @@ +bind { + http = ":65520" + https = ":65521" + metrics = ":65522" +} + +domain "http.internal" { + tls { + cert = "./testdata/selfsigned.crt" + key = "./testdata/selfsigned.key" + } + + target = "http://localhost:65510" # XXX(Xe) this is overwritten + health_target = "http://localhost:9091/healthz" +} + +domain "https.internal" { + tls { + cert = "./testdata/selfsigned.crt" + key = "./testdata/selfsigned.key" + } + + target = "https://localhost:65511" # XXX(Xe) this is overwritten + insecure_skip_verify = true + health_target = "http://localhost:9091/healthz" +} + +domain "h2c.internal" { + tls { + cert = "./testdata/selfsigned.crt" + key = "./testdata/selfsigned.key" + } + + target = "h2c://localhost:65511" # XXX(Xe) this is overwritten + health_target = "http://localhost:9091/healthz" +} + +domain "unix.internal" { + tls { + cert = "./testdata/selfsigned.crt" + key = "./testdata/selfsigned.key" + } + + target = "http://localhost:65511" # XXX(Xe) this is overwritten + health_target = "http://localhost:9091/healthz" +} \ No newline at end of file diff --git a/cmd/osiris/internal/entrypoint/testdata/good/selfsigned.hcl b/cmd/osiris/internal/entrypoint/testdata/good/selfsigned.hcl new file mode 100644 index 00000000..488add6b --- /dev/null +++ b/cmd/osiris/internal/entrypoint/testdata/good/selfsigned.hcl @@ -0,0 +1,15 @@ +bind { + http = ":65530" + https = ":65531" + metrics = ":65532" +} + +domain "osiris.local.cetacean.club" { + tls { + cert = "./testdata/selfsigned.crt" + key = "./testdata/selfsigned.key" + } + + target = "http://localhost:3000" + health_target = "http://localhost:9091/healthz" +} \ No newline at end of file diff --git a/cmd/osiris/internal/entrypoint/testdata/selfsigned.crt b/cmd/osiris/internal/entrypoint/testdata/selfsigned.crt new file mode 100644 index 00000000..c8f3412a --- /dev/null +++ b/cmd/osiris/internal/entrypoint/testdata/selfsigned.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBnzCCAVGgAwIBAgIUOLTjSYOjFk00IemtFTC4oEZs988wBQYDK2VwMEUxCzAJ +BgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5l +dCBXaWRnaXRzIFB0eSBMdGQwHhcNMjUwNzE4MjEyNDIzWhcNMjUwODE3MjEyNDIz +WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwY +SW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMCowBQYDK2VwAyEAPHphABS15+4VV6R1 +vYzBQYIycQmOmlbA8QcfwzuB2VajUzBRMB0GA1UdDgQWBBT2s+MQ4AR6cbK4V0+d +XZnok1orhDAfBgNVHSMEGDAWgBT2s+MQ4AR6cbK4V0+dXZnok1orhDAPBgNVHRMB +Af8EBTADAQH/MAUGAytlcANBAOdoJbRMnHmkEETzVtXP+jkAI9yQNRXujnglApGP +8I5pvIYVgYCgoQrnb4haVWFldHM1T9H698n19e/egfFb+w4= +-----END CERTIFICATE----- diff --git a/cmd/osiris/internal/entrypoint/testdata/selfsigned.key b/cmd/osiris/internal/entrypoint/testdata/selfsigned.key new file mode 100644 index 00000000..dcda2b85 --- /dev/null +++ b/cmd/osiris/internal/entrypoint/testdata/selfsigned.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIBop42tiZ0yzhaKo9NAc0PlAyBsE8NAE0i9Z7s2lgZuR +-----END PRIVATE KEY----- diff --git a/cmd/osiris/main.go b/cmd/osiris/main.go index ed891438..18ff7525 100644 --- a/cmd/osiris/main.go +++ b/cmd/osiris/main.go @@ -1,9 +1,12 @@ package main import ( + "context" "flag" "fmt" "os" + "os/signal" + "syscall" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/cmd/osiris/internal/entrypoint" @@ -28,7 +31,10 @@ func main() { internal.InitSlog(*slogLevel) - if err := entrypoint.Main(entrypoint.Options{ + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if err := entrypoint.Main(ctx, entrypoint.Options{ ConfigFname: *configFname, }); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) diff --git a/internal/fingerprint/ja4.go b/internal/fingerprint/ja4.go index cee0143d..0d272fd2 100644 --- a/internal/fingerprint/ja4.go +++ b/internal/fingerprint/ja4.go @@ -58,8 +58,6 @@ func buildJA4(hello *tls.ClientHelloInfo) (ja4 TLSFingerprintJA4) { } switch sslVersion { - case tls.VersionSSL30: - buf = append(buf, 's', '3') case tls.VersionTLS10: buf = append(buf, '1', '0') case tls.VersionTLS11: