wg-portal/internal/app/api/core/server.go
h44z d596f578f6
API - CRUD for peers, interfaces and users (#340)
Public REST API implementation to handle peers, interfaces and users. It also includes some simple provisioning endpoints.

The Swagger API documentation is available under /api/v1/doc.html
2025-01-11 18:44:55 +01:00

193 lines
4.8 KiB
Go

package core
import (
"context"
"encoding/base64"
"fmt"
"html/template"
"io"
"io/fs"
"math/rand"
"net/http"
"os"
"time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/config"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
ginlogrus "github.com/toorop/gin-logrus"
)
var (
random = rand.New(rand.NewSource(time.Now().UTC().UnixNano()))
)
const (
RequestIDKey = "X-Request-ID"
)
type ApiVersion string
type HandlerName string
type GroupSetupFn func(group *gin.RouterGroup)
type ApiEndpointSetupFunc func() (ApiVersion, GroupSetupFn)
type Server struct {
cfg *config.Config
server *gin.Engine
versions map[ApiVersion]*gin.RouterGroup
}
func NewServer(cfg *config.Config, endpoints ...ApiEndpointSetupFunc) (*Server, error) {
s := &Server{
cfg: cfg,
}
hostname, err := os.Hostname()
if err != nil {
hostname = "apiserver"
}
hostname += ", version " + internal.Version
// Setup http server
gin.SetMode(gin.ReleaseMode)
gin.DefaultWriter = io.Discard
s.server = gin.New()
if cfg.Web.RequestLogging {
if logrus.GetLevel() == logrus.TraceLevel {
gin.SetMode(gin.DebugMode)
s.server.Use(ginlogrus.Logger(logrus.StandardLogger()))
} else {
s.server.Use(ginlogrus.Logger(logrus.StandardLogger()))
}
}
s.server.Use(gin.Recovery()).Use(func(c *gin.Context) {
c.Writer.Header().Set("X-Served-By", hostname)
c.Next()
}).Use(func(c *gin.Context) {
xRequestID := uuid(16)
c.Request.Header.Set(RequestIDKey, xRequestID)
c.Set(RequestIDKey, xRequestID)
c.Next()
})
// Setup templates
templates := template.Must(template.New("").Funcs(s.server.FuncMap).ParseFS(apiTemplates, "assets/tpl/*.gohtml"))
s.server.SetHTMLTemplate(templates)
// Serve static files
imgFs := http.FS(fsMust(fs.Sub(apiStatics, "assets/img")))
s.server.StaticFS("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css"))))
s.server.StaticFS("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js"))))
s.server.StaticFS("/img", imgFs)
s.server.StaticFS("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts"))))
s.server.StaticFS("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc"))))
// Setup routes
s.server.UseRawPath = true
s.server.UnescapePathValues = true
s.setupRoutes(endpoints...)
s.setupFrontendRoutes()
return s, nil
}
func (s *Server) Run(ctx context.Context, listenAddress string) {
// Run web service
srv := &http.Server{
Addr: listenAddress,
Handler: s.server,
}
srvContext, cancelFn := context.WithCancel(ctx)
go func() {
var err error
if s.cfg.Web.CertFile != "" && s.cfg.Web.KeyFile != "" {
err = srv.ListenAndServeTLS(s.cfg.Web.CertFile, s.cfg.Web.KeyFile)
} else {
err = srv.ListenAndServe()
}
if err != nil {
logrus.Infof("web service on %s exited: %v", listenAddress, err)
cancelFn()
}
}()
logrus.Infof("started web service on %s", listenAddress)
// Wait for the main context to end
<-srvContext.Done()
logrus.Debug("web service shutting down, grace period: 5 seconds...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
logrus.Debug("web service shut down")
}
func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
s.server.GET("/api", s.landingPage)
s.versions = make(map[ApiVersion]*gin.RouterGroup)
for _, setupFunc := range endpoints {
version, groupSetupFn := setupFunc()
if _, ok := s.versions[version]; !ok {
s.versions[version] = s.server.Group(fmt.Sprintf("/api/%s", version))
// OpenAPI documentation (via RapiDoc)
s.versions[version].GET("/swagger/index.html", s.rapiDocHandler(version)) // Deprecated: old link
s.versions[version].GET("/doc.html", s.rapiDocHandler(version))
groupSetupFn(s.versions[version])
}
}
}
func (s *Server) setupFrontendRoutes() {
// Serve static files
s.server.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/app")
})
s.server.GET("/favicon.ico", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/app/favicon.ico")
})
s.server.StaticFS("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
}
func (s *Server) landingPage(c *gin.Context) {
c.HTML(http.StatusOK, "index.gohtml", gin.H{
"Version": internal.Version,
"Year": time.Now().Year(),
})
}
func (s *Server) rapiDocHandler(version ApiVersion) gin.HandlerFunc {
return func(c *gin.Context) {
c.HTML(http.StatusOK, "rapidoc.gohtml", gin.H{
"RapiDocSource": "/js/rapidoc-min.js",
"ApiSpecUrl": fmt.Sprintf("/doc/%s_swagger.yaml", version),
"Version": internal.Version,
"Year": time.Now().Year(),
})
}
}
func fsMust(f fs.FS, err error) fs.FS {
if err != nil {
panic(err)
}
return f
}
func uuid(len int) string {
bytes := make([]byte, len)
random.Read(bytes)
return base64.StdEncoding.EncodeToString(bytes)[:len]
}