This commit is contained in:
lujian 2026-01-20 16:00:29 +01:00 committed by GitHub
commit ee014f285d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 266 additions and 1 deletions

View file

@ -109,6 +109,7 @@ sequentially through each stable release, selecting the latest patch version ava
- Fix autogroup:self preventing visibility of nodes matched by other ACL rules [#2882](https://github.com/juanfont/headscale/pull/2882)
- Fix nodes being rejected after pre-authentication key expiration [#2917](https://github.com/juanfont/headscale/pull/2917)
- Fix list-routes command respecting identifier filter with JSON output [#2927](https://github.com/juanfont/headscale/pull/2927)
- Add support for serving built-in static web interfaces (e.g. admin or console UIs) directly. [#2982](https://github.com/juanfont/headscale/pull/2982)
## 0.27.1 (2025-11-11)

View file

@ -434,3 +434,35 @@ taildrop:
# #
# # node_store_batch_size: 100
# # node_store_batch_timeout: 500ms
# Web UI configuration
#
# This section controls the built-in static web file serving.
# It is typically used to expose:
# - Admin UI (SPA-based management console)
#
# The web server is served by headscale itself and shares
# the same listen_addr as the main HTTP server.
web:
# If disabled, no static files will be served at all.
enabled: true
# Web apps map
apps:
# Admin Web UI
# This UI is typically a Single Page Application (SPA),
admin:
# URL path prefix where the admin UI is exposed.
# Example: https://headscale.example.com/admin
url_path: /admin
# Filesystem path containing the static assets.
# Must include index.html.
root: /web/admin
# Enable SPA mode.
#
# When enabled:
# - Requests for non-existent files will fall back to index.html
# - This allows frontend routing to work correctly
#
# Disable this for traditional multi-page static sites.
spa: true

View file

@ -1,5 +1,33 @@
# Web interfaces for headscale
Headscale also supports serving static web applications directly from the server, without deploying an external web frontend. This can be used for admin panels, consoles, or other custom management interfaces.
## Configuration
Add a web section in your config.yaml:
```yaml
web:
enabled: true # Enable built-in web server
apps:
admin:
url_path: /admin # URL path to access the app
root: /web/admin # Directory containing static files
spa: true # Enable SPA mode (fallback to index.html)
console:
url_path: /console
root: /web/console
spa: false
```
- enabled: Enables or disables the internal web server. Default is false.
- url_path: Path where the app will be served.
- root: Directory containing static files to serve.
- spa: If true, unknown routes fallback to index.html, suitable for single-page applications.
## Community contributions
!!! warning "Community contributions"
This page contains community contributions. The projects listed here are not

View file

@ -474,6 +474,8 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *mux.Router {
router.HandleFunc("/bootstrap-dns", derpServer.DERPBootstrapDNSHandler(h.state.DERPMap()))
}
RegisterWebApps(router, h.cfg.Web)
apiRouter := router.PathPrefix("/api").Subrouter()
apiRouter.Use(h.httpAuthenticationMiddleware)
apiRouter.PathPrefix("/v1/").HandlerFunc(grpcMux.ServeHTTP)
@ -751,7 +753,6 @@ func (h *Headscale) Serve() error {
log.Info().Msg("metrics server disabled (metrics_listen_addr is empty)")
}
var tailsqlContext context.Context
if tailsqlEnabled {
if h.cfg.Database.Type != types.DatabaseSqlite {

View file

@ -101,6 +101,8 @@ type Config struct {
Policy PolicyConfig
Tuning Tuning
Web WebConfig
}
type DNSConfig struct {
@ -401,6 +403,8 @@ func LoadConfig(path string, isFile bool) error {
viper.SetDefault("prefixes.allocation", string(IPAllocationStrategySequential))
viper.SetDefault("web.enabled", false)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
log.Warn().Msg("No config file found, using defaults")
@ -1225,3 +1229,15 @@ func (d *deprecator) Log() {
log.Warn().Msg("\n" + d.String())
}
}
// WebConfig represents the web configuration.
type WebConfig struct {
Enabled bool `yaml:"enabled"`
Apps map[string]WebAppConfig `yaml:"apps"`
}
type WebAppConfig struct {
URLPath string `yaml:"url_path"`
Root string `yaml:"root"`
SPA bool `yaml:"spa"`
}

90
hscontrol/web.go Normal file
View file

@ -0,0 +1,90 @@
package hscontrol
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gorilla/mux"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log"
)
// RegisterWebApps registers the web apps configured in the config.
func RegisterWebApps(router *mux.Router, cfg types.WebConfig) {
if !cfg.Enabled {
return
}
for name, app := range cfg.Apps {
if app.URLPath == "" || app.Root == "" {
log.Warn().
Str("name", name).
Msg("skipping web app with incomplete configuration")
continue
}
log.Info().
Str("name", name).
Str("root", app.Root).
Str("url_path", app.URLPath).
Str("spa", fmt.Sprintf("%v", app.SPA)).
Msg("registering web app")
handler := fileHandler(app.URLPath, app.Root, app.SPA)
router.
PathPrefix(app.URLPath).
Handler(handler)
}
}
func fileHandler(urlPath, root string, spa bool) http.Handler {
root = filepath.Clean(root)
fs := http.FileServer(http.Dir(root))
rootIndex := filepath.Join(root, "index.html")
return http.StripPrefix(urlPath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.NotFound(w, r)
return
}
reqPath := filepath.Clean(r.URL.Path)
fullPath := filepath.Join(root, reqPath)
if !strings.HasPrefix(fullPath, root) {
http.NotFound(w, r)
return
}
info, err := os.Stat(fullPath)
if err == nil {
if info.IsDir() {
// Directory: serve index.html if exists
indexFile := filepath.Join(fullPath, "index.html")
if _, err := os.Stat(indexFile); err == nil {
http.ServeFile(w, r, indexFile)
return
}
} else {
// Regular file
fs.ServeHTTP(w, r)
return
}
}
// SPA fallback
if spa {
if _, err := os.Stat(rootIndex); err == nil {
http.ServeFile(w, r, rootIndex)
return
}
}
// Not found
http.NotFound(w, r)
}))
}

97
hscontrol/web_test.go Normal file
View file

@ -0,0 +1,97 @@
package hscontrol
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gorilla/mux"
"github.com/juanfont/headscale/hscontrol/types"
)
func TestRegisterWebApps(t *testing.T) {
tmpDir := t.TempDir()
// index.hyml
// index.js
os.WriteFile(filepath.Join(tmpDir, "index.html"), []byte("root index"), 0644)
os.WriteFile(filepath.Join(tmpDir, "index.js"), []byte("root js"), 0644)
// /dir1/index.html
// /dir1/1.js
dir1 := filepath.Join(tmpDir, "dir1")
os.MkdirAll(dir1, 0755)
os.WriteFile(filepath.Join(dir1, "index.html"), []byte("dir1 index"), 0644)
os.WriteFile(filepath.Join(dir1, "1.js"), []byte("dir1 js"), 0644)
// /dir2/2.js
dir2 := filepath.Join(tmpDir, "dir2")
os.MkdirAll(dir2, 0755)
os.WriteFile(filepath.Join(dir2, "2.js"), []byte("dir2 js"), 0644)
prefix := "/app" // simulate URLPath prefix
tests := []struct {
path string
expected string
status int
spa bool
}{
// SPA mode
{prefix + "/", "root index", http.StatusOK, true},
{prefix + "/index.js", "root js", http.StatusOK, true},
{prefix + "/dir1/", "dir1 index", http.StatusOK, true},
{prefix + "/dir1/1.js", "dir1 js", http.StatusOK, true},
{prefix + "/dir2/", "root index", http.StatusOK, true}, // fallback to root index.html
{prefix + "/dir2/2.js", "dir2 js", http.StatusOK, true},
{prefix + "/dir3/", "root index", http.StatusOK, true}, // non-existent directory fallback
{prefix + "/dir3/file", "root index", http.StatusOK, true},
// Non-SPA mode
{prefix + "/", "root index", http.StatusOK, false},
{prefix + "/index.js", "root js", http.StatusOK, false},
{prefix + "/dir1/", "dir1 index", http.StatusOK, false},
{prefix + "/dir1/1.js", "dir1 js", http.StatusOK, false},
{prefix + "/dir2/", "", http.StatusNotFound, false}, // no index.html
{prefix + "/dir2/2.js", "dir2 js", http.StatusOK, false},
{prefix + "/dir3/", "", http.StatusNotFound, false},
{prefix + "/dir3/file", "", http.StatusNotFound, false},
}
// Note: Accessing any "/*/index.html" file via FileServer or ServeFile
// will automatically trigger a 301 redirect to the directory path with trailing '/',
// e.g., "/dir1/index.html" -> "/dir1/".
// This is default behavior of Go's http.FileServer for directories.
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
router := mux.NewRouter()
cfg := types.WebConfig{
Enabled: true,
Apps: map[string]types.WebAppConfig{
"app": {
URLPath: prefix,
Root: tmpDir,
SPA: tt.spa,
},
},
}
RegisterWebApps(router, cfg)
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
// Check HTTP status
if rr.Code != tt.status {
t.Fatalf("path %q: expected status %d, got %d head %s", tt.path, tt.status, rr.Code, rr.Header())
}
// Check response content if expected
if tt.expected != "" && !strings.Contains(rr.Body.String(), tt.expected) {
t.Fatalf("path %q: expected body to contain %q, got %q", tt.path, tt.expected, rr.Body.String())
}
})
}
}