Gist: Go HTTPS server boilerplate
Some boilerplate code for a Go HTTPS server.
Last updated on: 2024-12-29
package myapp
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)
type Config struct {
Address string `json:"address"` // HTTP server local address.
Domain string `json:"domain"` // Domain (required for TLS certificates).
ACMEDirpath string `json:"acme_dirpath"` // Path to TLS certificate directory.
ACMEEmailAddress string `json:"acme_email_address"` // Email address for CAs and TLS certificates.
ACMEServerURL string `json:"acme_server_url"` // Remote server URL of ACME server.
}
func Run() (exitcode int) {
startedAt := time.Now()
// Configure logger.
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
logger.Info("starting up")
// Load config.
conf, err := loadConfigFromJSONFile("conf.json")
if err != nil {
logger.Error("load config", "error", err)
return 1
}
// Listen for interrupt signal (and notify context when received).
mainCtx, mainCtxCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer mainCtxCancel()
// Configure HTTP handler.
mainHandler := http.NotFoundHandler()
// Optional for HTTPS, run autocert server on port :80.
var tlsConfig *tls.Config
var autocertServer *http.Server
var autocertServerErrC chan error
if conf.ACMEDirpath != "" {
if conf.ACMEEmailAddress == "" {
logger.Error("missing field in config", "key", "TLSEmailAddress")
return 1
}
if conf.ACMEServerURL == "" {
conf.ACMEServerURL = autocert.DefaultACMEDirectory
}
// Ensure certs directory exists.
fstat, err := os.Stat(conf.ACMEDirpath)
if err != nil {
logger.Error("stat TLS certs directory", "error", err)
return 1
} else if !fstat.IsDir() {
logger.Error("stat TLS certs directory", "error", "not a directory")
return 1
}
// Run autocert HTTP server on port 80.
tlsCertManager := autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(conf.Domain),
Cache: autocert.DirCache(conf.ACMEDirpath),
Email: conf.ACMEEmailAddress,
Client: &acme.Client{DirectoryURL: conf.ACMEServerURL},
}
tlsConfig = tlsCertManager.TLSConfig()
autocertServerErrLogger := slog.NewLogLogger(logger.Handler(), slog.LevelDebug)
autocertServerErrLogger.SetPrefix("autocert HTTP server:")
autocertServer = &http.Server{
Addr: ":80",
Handler: tlsCertManager.HTTPHandler(nil),
ReadTimeout: 20 * time.Second,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 10 * time.Second,
MaxHeaderBytes: 100_000,
ErrorLog: autocertServerErrLogger,
BaseContext: func(_ net.Listener) context.Context { return mainCtx },
}
logger.Debug("starting autocert server", "address", autocertServer.Addr)
autocertServerErrC = make(chan error, 1)
go func() {
err := autocertServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
autocertServerErrC <- err
}
}()
}
// Run app HTTP(S) server.
if conf.ACMEDirpath != "" {
conf.Address = ":443"
} else if conf.Address == "" {
conf.Address = ":8080"
}
appServerErrLogger := slog.NewLogLogger(logger.Handler(), slog.LevelDebug)
appServerErrLogger.SetPrefix("app HTTP server:")
appHTTPServer := &http.Server{
Addr: conf.Address,
Handler: mainHandler,
ReadTimeout: 20 * time.Second,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 10 * time.Second,
MaxHeaderBytes: 100_000,
ErrorLog: appServerErrLogger,
BaseContext: func(_ net.Listener) context.Context { return mainCtx },
TLSConfig: tlsConfig,
}
logger.Debug("starting app server", "address", appHTTPServer.Addr)
appServerErrC := make(chan error, 1)
go func() {
var err error
if conf.ACMEDirpath != "" {
err = appHTTPServer.ListenAndServeTLS("", "")
} else {
err = appHTTPServer.ListenAndServe()
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
appServerErrC <- err
}
}()
// Wait for interrupt signal or server error.
select {
case <-mainCtx.Done():
logger.Debug("received exit signal", "cause", mainCtx.Err())
case err := <-appServerErrC:
exitcode = 1
logger.Error("HTTP app server exited", "error", err)
case err := <-autocertServerErrC:
exitcode = 1
logger.Error("HTTP autocert server exited", "error", err)
}
exitCtx, exitCtxCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer exitCtxCancel()
// Shutdown app HTTP server.
err = appHTTPServer.Shutdown(exitCtx)
if err != nil {
exitcode = 1
logger.Error("app HTTP server shutdown failed", "error", err)
} else {
logger.Info("app HTTP server shutdown complete")
}
// Shutdown autocert HTTP server (if needed).
if autocertServer != nil {
err = autocertServer.Shutdown(exitCtx)
if err != nil {
exitcode = 1
logger.Error("autocert HTTP server shutdown failed", "error", err)
} else {
logger.Info("autocert HTTP server shutdown complete")
}
}
logger.Info("exiting", "exitcode", exitcode, "started_at", startedAt, "uptime", time.Since(startedAt).String())
return exitcode
}
func loadConfigFromJSONFile(configPath string) (conf *Config, err error) {
f, err := os.Open(configPath)
if os.IsNotExist(err) {
return &Config{}, nil
} else if err != nil {
return nil, fmt.Errorf("open config file: %w", err)
}
defer f.Close()
conf = &Config{}
err = json.NewDecoder(f).Decode(conf)
if err != nil {
return nil, fmt.Errorf("decode JSON config file: %w", err)
}
return conf, nil
}