mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Six env flags, two conventions, and both failed silently against the other. Five read `== "true"`, so BOOTNODE_ENABLED=1 left bootnode off and CALENDAR_ENABLED=1 left the calendar off. One read `!= "1"`, so BASE_ENABLE_ADMIN_UI=true served no admin. And ZAP_DISABLED read `!= "true"`, so an operator who wrote ZAP_DISABLED=1 to stop the listener watched it come up anyway. In every case the flag was accepted, ignored, and never mentioned again. This tree already answers "read a boolean from a string" four times, with strconv.ParseBool (tools/filesystem, tools/search, tools/router, core/settings_query). The hand-rolled comparisons were the deviation, so osutils.Bool is that answer given a name and a default, and all six sites read it. Strictly more permissive: every spelling that worked before still works, and the ones that silently did nothing now do what they say. An unparseable value yields the default rather than its opposite — someone who typed something meaningless has not asked for the other thing. Coverage: plugins/zap 5.9% -> 23.5%, and DefaultConfig is pinned including the case that started this (ZAP_DISABLED=1 must disable) and a garbage ZAP_PORT, which must keep 9999 rather than quietly binding somewhere else. Co-authored-by: Hanzo Dev <[email protected]>
408 lines
13 KiB
Go
408 lines
13 KiB
Go
package apis
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/tools/osutils"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/hook"
|
|
"github.com/hanzoai/base/tools/list"
|
|
"github.com/hanzoai/base/tools/routine"
|
|
uireact "github.com/hanzoai/base/ui-react"
|
|
"golang.org/x/crypto/acme"
|
|
"golang.org/x/crypto/acme/autocert"
|
|
)
|
|
|
|
// frameAncestors reports who may put the admin in a frame, as a CSP source
|
|
// list. Default "'self'", which is this origin and nobody else.
|
|
//
|
|
// It is a knob because the answer is deployment data, like IAM_ENDPOINT: the
|
|
// admin is one UI that renders standalone at its own host AND inside the Hanzo
|
|
// surfaces that offer Base as a section, and a Base someone else runs has
|
|
// different neighbours. base.hanzo.ai names the Hanzo hosts in universe; the
|
|
// repos that embed Base as a library say nothing and stay same-origin.
|
|
//
|
|
// `frame-ancestors` and not X-Frame-Options: XFO can say "nobody" or "same
|
|
// origin" and cannot name a host, so an allow-list is the one thing it cannot
|
|
// express. This is what the ingress already does in front of hanzo.id.
|
|
func frameAncestors() string {
|
|
if v := strings.TrimSpace(os.Getenv("BASE_FRAME_ANCESTORS")); v != "" {
|
|
return v
|
|
}
|
|
return "'self'"
|
|
}
|
|
|
|
// ServeConfig defines a configuration struct for apis.Serve().
|
|
type ServeConfig struct {
|
|
// ShowStartBanner indicates whether to show or hide the server start console message.
|
|
ShowStartBanner bool
|
|
|
|
// HttpAddr is the TCP address to listen for the HTTP server (eg. "127.0.0.1:80").
|
|
HttpAddr string
|
|
|
|
// HttpsAddr is the TCP address to listen for the HTTPS server (eg. "127.0.0.1:443").
|
|
HttpsAddr string
|
|
|
|
// Optional domains list to use when issuing the TLS certificate.
|
|
//
|
|
// If not set, the host from the bound server address will be used.
|
|
//
|
|
// For convenience, for each "non-www" domain a "www" entry and
|
|
// redirect will be automatically added.
|
|
CertificateDomains []string
|
|
|
|
// AllowedOrigins is an optional list of CORS origins (default to "*").
|
|
AllowedOrigins []string
|
|
}
|
|
|
|
// Serve starts a new app web server.
|
|
//
|
|
// NB! The app should be bootstrapped before starting the web server.
|
|
//
|
|
// Example:
|
|
//
|
|
// app.Bootstrap()
|
|
// apis.Serve(app, apis.ServeConfig{
|
|
// HttpAddr: "127.0.0.1:8080",
|
|
// ShowStartBanner: false,
|
|
// })
|
|
func Serve(app core.App, config ServeConfig) error {
|
|
if len(config.AllowedOrigins) == 0 {
|
|
config.AllowedOrigins = []string{"*"}
|
|
}
|
|
|
|
// ensure that the latest migrations are applied before starting the server
|
|
err := app.RunAllMigrations()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
baseRouter, err := NewRouter(app)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
baseRouter.Bind(CORS(CORSConfig{
|
|
AllowOrigins: config.AllowedOrigins,
|
|
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
|
}))
|
|
|
|
// The admin is served at the ROOT. base.hanzo.ai IS the admin, so it lives at
|
|
// the address people type; the API answers under /v1, which is a longer
|
|
// pattern and therefore wins on every request that belongs to it.
|
|
//
|
|
// It used to live at /_/ with a knob to move it and a second knob to redirect
|
|
// / to wherever the first one pointed. Three things had to agree — the server
|
|
// path, the SPA's build-time base, and the redirect URI registered with IAM —
|
|
// and every asset addressed from the root landed on the fallback below instead
|
|
// of the file, which is a 200 carrying HTML where an image was asked for.
|
|
//
|
|
// Admin UI: disabled by default. Set BASE_ENABLE_ADMIN_UI=1 to serve the
|
|
// dashboard. Production services are headless APIs — no UI surface exposed.
|
|
if !osutils.Bool("BASE_ENABLE_ADMIN_UI", false) {
|
|
baseRouter.GET("/{path...}", func(e *core.RequestEvent) error {
|
|
// Two different questions land here and they want different answers.
|
|
// A browser navigating to the dashboard is asking about the admin,
|
|
// and naming the switch is the useful reply. Everything else asked
|
|
// for a path that does not exist — and answering THAT with "admin UI
|
|
// disabled" sends the reader after a setting which has nothing to do
|
|
// with the failure. It is what a caller of /api/platform/... was
|
|
// told: a route that was never implemented reported itself as a
|
|
// disabled dashboard, so the search started in the wrong place.
|
|
if strings.Contains(e.Request.Header.Get("Accept"), "text/html") {
|
|
return e.JSON(http.StatusNotFound, map[string]string{
|
|
"error": "this deployment serves no admin UI; set BASE_ENABLE_ADMIN_UI=1 to enable it",
|
|
})
|
|
}
|
|
|
|
return e.NotFoundError("", nil)
|
|
})
|
|
} else {
|
|
// indexFallback=true so deep links (settings/auth, records/...) hit
|
|
// the SPA index.html and let React Router resolve them client-side.
|
|
baseRouter.GET("/{path...}", Static(uireact.DistDirFS(), true)).
|
|
BindFunc(func(e *core.RequestEvent) error {
|
|
// A FORTNIGHT'S CACHE BELONGS TO CONTENT-ADDRESSED FILES ONLY.
|
|
//
|
|
// This route falls back to index.html for any path it cannot
|
|
// find, which is what makes deep links work — but the fallback
|
|
// is served UNDER THE REQUESTED URL, at 200. Cached for two
|
|
// weeks, a path that does not exist YET becomes HTML for two
|
|
// weeks after the release that adds it.
|
|
//
|
|
// Measured, not hypothetical: /brands/hanzo/logo.svg was
|
|
// requested before the release carrying it, the edge stored the
|
|
// fallback, and after the release it still answered
|
|
// text/html at 200 with age=2888 — the logo rendering broken
|
|
// with nothing in the console, exactly as ui-react/src/icon.ts
|
|
// warns. Its siblings, never requested early, were fine.
|
|
//
|
|
// Only /assets carries a content hash in the name, so only
|
|
// /assets can be immutable. Everything else gets a short cache:
|
|
// long enough to spare the origin, short enough that a stale
|
|
// answer heals itself.
|
|
switch path := e.Request.PathValue(StaticWildcardParam); {
|
|
case path == "": // root
|
|
case strings.HasPrefix(path, "assets/"):
|
|
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
|
default:
|
|
e.Response.Header().Set("Cache-Control", "max-age=300, stale-while-revalidate=300")
|
|
}
|
|
|
|
// add a default CSP
|
|
if e.Response.Header().Get("Content-Security-Policy") == "" {
|
|
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; img-src 'self' http://127.0.0.1:* https: data: blob:; connect-src 'self' http://127.0.0.1:* https:; script-src 'self' 'unsafe-inline'; frame-ancestors "+frameAncestors())
|
|
}
|
|
|
|
return e.Next()
|
|
}).
|
|
Bind(Gzip())
|
|
}
|
|
|
|
// start http server
|
|
// ---
|
|
mainAddr := config.HttpAddr
|
|
if config.HttpsAddr != "" {
|
|
mainAddr = config.HttpsAddr
|
|
}
|
|
|
|
var wwwRedirects []string
|
|
|
|
// extract the host names for the certificate host policy
|
|
hostNames := config.CertificateDomains
|
|
if len(hostNames) == 0 {
|
|
host, _, _ := net.SplitHostPort(mainAddr)
|
|
hostNames = append(hostNames, host)
|
|
}
|
|
for _, host := range hostNames {
|
|
if strings.HasPrefix(host, "www.") {
|
|
continue // explicitly set www host
|
|
}
|
|
|
|
wwwHost := "www." + host
|
|
if !list.ExistInSlice(wwwHost, hostNames) {
|
|
hostNames = append(hostNames, wwwHost)
|
|
wwwRedirects = append(wwwRedirects, wwwHost)
|
|
}
|
|
}
|
|
|
|
// implicit www->non-www redirect(s)
|
|
if len(wwwRedirects) > 0 {
|
|
baseRouter.Bind(wwwRedirect(wwwRedirects))
|
|
}
|
|
|
|
certManager := &autocert.Manager{
|
|
Prompt: autocert.AcceptTOS,
|
|
Cache: autocert.DirCache(filepath.Join(app.DataDir(), core.LocalAutocertCacheDirName)),
|
|
HostPolicy: autocert.HostWhitelist(hostNames...),
|
|
}
|
|
|
|
// base request context used for cancelling long running requests
|
|
// like the SSE connections
|
|
baseCtx, cancelBaseCtx := context.WithCancel(context.Background())
|
|
defer cancelBaseCtx()
|
|
|
|
server := &http.Server{
|
|
// higher defaults to accommodate large file uploads/downloads
|
|
WriteTimeout: 5 * time.Minute,
|
|
ReadTimeout: 5 * time.Minute,
|
|
ReadHeaderTimeout: 1 * time.Minute,
|
|
Addr: mainAddr,
|
|
BaseContext: func(l net.Listener) context.Context {
|
|
return baseCtx
|
|
},
|
|
ErrorLog: log.New(&serverErrorLogWriter{app: app}, "", 0),
|
|
}
|
|
|
|
// Only set TLS config when HTTPS is explicitly enabled.
|
|
// Without this guard, Go's http.Server auto-upgrades connections
|
|
// to TLS when TLSConfig is present, breaking HTTP health probes
|
|
// and ingress backends that connect via plain HTTP.
|
|
if config.HttpsAddr != "" {
|
|
server.TLSConfig = &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
GetCertificate: certManager.GetCertificate,
|
|
NextProtos: []string{acme.ALPNProto},
|
|
}
|
|
}
|
|
|
|
var listener net.Listener
|
|
|
|
// graceful shutdown
|
|
// ---------------------------------------------------------------
|
|
// WaitGroup to block until server.ShutDown() returns because Serve and similar methods exit immediately.
|
|
// Note that the WaitGroup would do nothing if the app.OnTerminate() hook isn't triggered.
|
|
var wg sync.WaitGroup
|
|
|
|
// try to gracefully shutdown the server on app termination
|
|
app.OnTerminate().Bind(&hook.Handler[*core.TerminateEvent]{
|
|
Id: "baseGracefulShutdown",
|
|
Func: func(te *core.TerminateEvent) error {
|
|
cancelBaseCtx()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
|
defer cancel()
|
|
|
|
wg.Add(1)
|
|
|
|
_ = server.Shutdown(ctx)
|
|
|
|
if te.IsRestart {
|
|
// wait for execve and other handlers up to 3 seconds before exit
|
|
time.AfterFunc(3*time.Second, func() {
|
|
wg.Done()
|
|
})
|
|
} else {
|
|
wg.Done()
|
|
}
|
|
|
|
return te.Next()
|
|
},
|
|
Priority: -9999,
|
|
})
|
|
|
|
// wait for the graceful shutdown to complete before exit
|
|
defer func() {
|
|
wg.Wait()
|
|
|
|
if listener != nil {
|
|
_ = listener.Close()
|
|
}
|
|
}()
|
|
// ---------------------------------------------------------------
|
|
|
|
var baseURL string
|
|
|
|
serveEvent := new(core.ServeEvent)
|
|
serveEvent.App = app
|
|
serveEvent.Router = baseRouter
|
|
serveEvent.Server = server
|
|
serveEvent.CertManager = certManager
|
|
serveEvent.InstallerFunc = DefaultInstallerFunc
|
|
|
|
// trigger the OnServe hook and start the tcp listener
|
|
serveHookErr := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
|
handler, err := e.Router.BuildMux()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
e.Server.Handler = handler
|
|
|
|
if config.HttpsAddr == "" {
|
|
baseURL = "http://" + serverAddrToHost(serveEvent.Server.Addr)
|
|
} else {
|
|
baseURL = "https://"
|
|
if len(config.CertificateDomains) > 0 {
|
|
baseURL += config.CertificateDomains[0]
|
|
} else {
|
|
baseURL += serverAddrToHost(serveEvent.Server.Addr)
|
|
}
|
|
}
|
|
|
|
addr := e.Server.Addr
|
|
if addr == "" {
|
|
// fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
|
|
if config.HttpsAddr != "" {
|
|
addr = ":https"
|
|
} else {
|
|
addr = ":http"
|
|
}
|
|
}
|
|
|
|
if e.Listener == nil {
|
|
listener, err = net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
listener = e.Listener
|
|
}
|
|
|
|
if e.InstallerFunc != nil {
|
|
app := e.App
|
|
installerFunc := e.InstallerFunc
|
|
routine.FireAndForget(func() {
|
|
if err := loadInstaller(app, baseURL, installerFunc); err != nil {
|
|
app.Logger().Warn("Failed to initialize installer", "error", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if serveHookErr != nil {
|
|
return serveHookErr
|
|
}
|
|
|
|
if listener == nil {
|
|
//nolint:staticcheck
|
|
return errors.New("The OnServe listener was not initialized. Did you forget to call the ServeEvent.Next() method?")
|
|
}
|
|
|
|
if config.ShowStartBanner {
|
|
date := new(strings.Builder)
|
|
log.New(date, "", log.LstdFlags).Print()
|
|
|
|
bold := color.New(color.Bold).Add(color.FgGreen)
|
|
bold.Printf(
|
|
"%s Server started at %s\n",
|
|
strings.TrimSpace(date.String()),
|
|
color.CyanString("%s", baseURL),
|
|
)
|
|
|
|
regular := color.New()
|
|
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/v1/", baseURL))
|
|
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/", baseURL))
|
|
}
|
|
|
|
var serveErr error
|
|
if config.HttpsAddr != "" {
|
|
if config.HttpAddr != "" {
|
|
// start an additional HTTP server for redirecting the traffic to the HTTPS version
|
|
go http.ListenAndServe(config.HttpAddr, certManager.HTTPHandler(nil))
|
|
}
|
|
|
|
// start HTTPS server
|
|
serveErr = serveEvent.Server.ServeTLS(listener, "", "")
|
|
} else {
|
|
// OR start HTTP server
|
|
serveErr = serveEvent.Server.Serve(listener)
|
|
}
|
|
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
|
return serveErr
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// serverAddrToHost loosely converts http.Server.Addr string into a host to print.
|
|
func serverAddrToHost(addr string) string {
|
|
if addr == "" || strings.HasSuffix(addr, ":http") || strings.HasSuffix(addr, ":https") {
|
|
return "127.0.0.1"
|
|
}
|
|
return addr
|
|
}
|
|
|
|
type serverErrorLogWriter struct {
|
|
app core.App
|
|
}
|
|
|
|
func (s *serverErrorLogWriter) Write(p []byte) (int, error) {
|
|
s.app.Logger().Debug(strings.TrimSpace(string(p)))
|
|
|
|
return len(p), nil
|
|
}
|