Files
zeekayandHanzo Dev 0fd5c6b353 an off-switch that does nothing is worse than one that is not there
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]>
2026-08-24 10:34:54 -07:00

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
}