diff --git a/handler/payload/ping.go b/handler/payload/ping.go new file mode 100644 index 00000000..d05f6805 --- /dev/null +++ b/handler/payload/ping.go @@ -0,0 +1,6 @@ +package payload + +type PingResponse struct { + Message string `json:"message"` + DateTime string `json:"date_time"` +} diff --git a/handler/ping.go b/handler/ping.go new file mode 100644 index 00000000..437a3150 --- /dev/null +++ b/handler/ping.go @@ -0,0 +1,45 @@ +package handler + +import ( + "fmt" + baseHttp "net/http" + "time" + + "github.com/oullin/handler/payload" + "github.com/oullin/metal/env" + "github.com/oullin/pkg/http" + "github.com/oullin/pkg/portal" +) + +type PingHandler struct { + env *env.Ping +} + +func MakePingHandler(e *env.Ping) PingHandler { + return PingHandler{env: e} +} + +func (h PingHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + user, pass, ok := r.BasicAuth() + + if !ok || h.env.HasInvalidCreds(user, pass) { + return http.LogUnauthorisedError( + "invalid credentials", + fmt.Errorf("invalid credentials"), + ) + } + + resp := http.MakeResponseFrom("0.0.1", w, r) + now := time.Now().UTC() + + data := payload.PingResponse{ + Message: "pong", + DateTime: now.Format(portal.DatesLayout), + } + + if err := resp.RespondOk(data); err != nil { + return http.LogInternalError("could not encode ping response", err) + } + + return nil +} diff --git a/handler/ping_test.go b/handler/ping_test.go new file mode 100644 index 00000000..2990d76d --- /dev/null +++ b/handler/ping_test.go @@ -0,0 +1,51 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/oullin/handler/payload" +) + +func TestPingHandler(t *testing.T) { + t.Setenv("PING_USERNAME", "user") + t.Setenv("PING_PASSWORD", "pass") + h := MakePingHandler() + + t.Run("valid credentials", func(t *testing.T) { + req := httptest.NewRequest("GET", "/ping", nil) + req.SetBasicAuth("user", "pass") + rec := httptest.NewRecorder() + if err := h.Handle(rec, req); err != nil { + t.Fatalf("handle err: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + var resp payload.PingResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Message != "pong" { + t.Fatalf("unexpected message: %s", resp.Message) + } + if _, err := time.Parse("2006-01-02", resp.Date); err != nil { + t.Fatalf("invalid date: %v", err) + } + if _, err := time.Parse("15:04:05", resp.Time); err != nil { + t.Fatalf("invalid time: %v", err) + } + }) + + t.Run("invalid credentials", func(t *testing.T) { + req := httptest.NewRequest("GET", "/ping", nil) + req.SetBasicAuth("bad", "creds") + rec := httptest.NewRecorder() + if err := h.Handle(rec, req); err == nil || err.Status != http.StatusUnauthorized { + t.Fatalf("expected unauthorized, got %#v", err) + } + }) +} diff --git a/metal/env/env.go b/metal/env/env.go index b2099d2d..186cc1d2 100644 --- a/metal/env/env.go +++ b/metal/env/env.go @@ -12,6 +12,7 @@ type Environment struct { Logs LogsEnvironment Network NetEnvironment Sentry SentryEnvironment + Ping Ping } // SecretsDir defines where secret files are read from. It can be overridden in diff --git a/metal/env/ping.go b/metal/env/ping.go new file mode 100644 index 00000000..a9c9cd7b --- /dev/null +++ b/metal/env/ping.go @@ -0,0 +1,21 @@ +package env + +import "strings" + +type Ping struct { + Username string `validate:"required,min=16"` + Password string `validate:"required,min=16"` +} + +func (p Ping) GetUsername() string { + return p.Username +} + +func (p Ping) GetPassword() string { + return p.Password +} + +func (p Ping) HasInvalidCreds(username, password string) bool { + return username != strings.TrimSpace(p.Username) || + password != strings.TrimSpace(p.Password) +} diff --git a/metal/kernel/app.go b/metal/kernel/app.go index b581f976..968d4dfc 100644 --- a/metal/kernel/app.go +++ b/metal/kernel/app.go @@ -69,6 +69,7 @@ func (a *App) Boot() { router := *a.router + router.Ping() router.Profile() router.Experience() router.Projects() diff --git a/metal/kernel/factory.go b/metal/kernel/factory.go index 12d6e96a..8eda65dc 100644 --- a/metal/kernel/factory.go +++ b/metal/kernel/factory.go @@ -95,6 +95,11 @@ func MakeEnv(validate *portal.Validator) *env.Environment { CSP: env.GetEnvVar("ENV_SENTRY_CSP"), } + pingEnvironment := env.Ping{ + Username: env.GetEnvVar("PING_USERNAME"), + Password: env.GetEnvVar("PING_PASSWORD"), + } + if _, err := validate.Rejects(app); err != nil { panic(errorSuffix + "invalid [APP] model: " + validate.GetErrorsAsJson()) } @@ -115,12 +120,17 @@ func MakeEnv(validate *portal.Validator) *env.Environment { panic(errorSuffix + "invalid [SENTRY] model: " + validate.GetErrorsAsJson()) } + if _, err := validate.Rejects(pingEnvironment); err != nil { + panic(errorSuffix + "invalid [ping] model: " + validate.GetErrorsAsJson()) + } + blog := &env.Environment{ App: app, DB: db, Logs: logsCreds, Network: net, Sentry: sentryEnvironment, + Ping: pingEnvironment, } if _, err := validate.Rejects(blog); err != nil { diff --git a/metal/kernel/router.go b/metal/kernel/router.go index 8b76564b..5e3df29b 100644 --- a/metal/kernel/router.go +++ b/metal/kernel/router.go @@ -80,6 +80,16 @@ func (r *Router) Signature() { r.Mux.HandleFunc("POST /generate-signature", generate) } +func (r *Router) Ping() { + abstract := handler.MakePingHandler(&r.Env.Ping) + + apiHandler := http.MakeApiHandler( + r.Pipeline.Chain(abstract.Handle), + ) + + r.Mux.HandleFunc("GET /ping", apiHandler) +} + func (r *Router) Profile() { addStaticRoute(r, "/profile", "./storage/fixture/profile.json", handler.MakeProfileHandler) } diff --git a/metal/kernel/router_ping_test.go b/metal/kernel/router_ping_test.go new file mode 100644 index 00000000..8d9a4308 --- /dev/null +++ b/metal/kernel/router_ping_test.go @@ -0,0 +1,67 @@ +package kernel + +import ( + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + "unsafe" + + "github.com/oullin/pkg/middleware" + "github.com/oullin/pkg/portal" +) + +func TestPingRoute_PublicMiddleware(t *testing.T) { + t.Setenv("PING_USERNAME", "user") + t.Setenv("PING_PASSWORD", "pass") + fixedTime := time.Unix(1700000000, 0) + pm := middleware.MakePublicMiddleware("", false) + rv := reflect.ValueOf(&pm).Elem().FieldByName("now") + reflect.NewAt(rv.Type(), unsafe.Pointer(rv.UnsafeAddr())).Elem().Set(reflect.ValueOf(func() time.Time { return fixedTime })) + + r := Router{ + Mux: http.NewServeMux(), + Pipeline: middleware.Pipeline{ + PublicMiddleware: pm, + }, + } + r.Ping() + + t.Run("request without public headers is unauthorized", func(t *testing.T) { + req := httptest.NewRequest("GET", "/ping", nil) + req.SetBasicAuth("user", "pass") + rec := httptest.NewRecorder() + r.Mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) + } + }) + + t.Run("request with public headers but invalid credentials is unauthorized", func(t *testing.T) { + req := httptest.NewRequest("GET", "/ping", nil) + req.SetBasicAuth("bad", "creds") + req.Header.Set(portal.RequestIDHeader, "req-1") + req.Header.Set(portal.TimestampHeader, fmt.Sprintf("%d", fixedTime.Unix())) + req.Header.Set("X-Forwarded-For", "1.2.3.4") + rec := httptest.NewRecorder() + r.Mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) + } + }) + + t.Run("request with public headers and valid credentials succeeds", func(t *testing.T) { + req := httptest.NewRequest("GET", "/ping", nil) + req.SetBasicAuth("user", "pass") + req.Header.Set(portal.RequestIDHeader, "req-2") + req.Header.Set(portal.TimestampHeader, fmt.Sprintf("%d", fixedTime.Unix())) + req.Header.Set("X-Forwarded-For", "1.2.3.4") + rec := httptest.NewRecorder() + r.Mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + }) +} diff --git a/pkg/http/response.go b/pkg/http/response.go index 7fef9eea..636f2d48 100644 --- a/pkg/http/response.go +++ b/pkg/http/response.go @@ -98,6 +98,15 @@ func LogBadRequestError(msg string, err error) *ApiError { } } +func LogUnauthorisedError(msg string, err error) *ApiError { + slog.Error(err.Error(), "error", err) + + return &ApiError{ + Message: fmt.Sprintf("Unauthorised request: %s", msg), + Status: baseHttp.StatusUnauthorized, + } +} + func UnprocessableEntity(msg string, errors map[string]any) *ApiError { return &ApiError{ Message: fmt.Sprintf("Unprocessable entity: %s", msg),