Julien's dev blog

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
}
Go boilerplate code for a HTTPS server