From 72f7956909561d26d6e4ef6a1561cc4afc163bae Mon Sep 17 00:00:00 2001 From: lujian Date: Mon, 22 Dec 2025 11:01:05 +0800 Subject: [PATCH 1/5] feat(web): add static file serving - Introduce web config to enable and configure static file serving - Implement RegisterWebApps to register configured web apps - Add fileHandler for serving files, supporting directory index and SPA fallback - Set default web config to disabled - Define WebConfig and WebAppConfig structs - Integrate web app registration into router - Include unit tests covering various file handling scenarios - Update config examples with web UI instructions --- config-example.yaml | 30 +++++++++++ hscontrol/app.go | 3 +- hscontrol/types/config.go | 16 ++++++ hscontrol/web.go | 90 +++++++++++++++++++++++++++++++ hscontrol/web_test.go | 109 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 hscontrol/web.go create mode 100644 hscontrol/web_test.go diff --git a/config-example.yaml b/config-example.yaml index 887e2ea9..d07caf60 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -427,3 +427,33 @@ 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 + # 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 + diff --git a/hscontrol/app.go b/hscontrol/app.go index aa011503..36f631ac 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -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 { diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 4068d72e..b88c6cc4 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -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"` +} diff --git a/hscontrol/web.go b/hscontrol/web.go new file mode 100644 index 00000000..e7ed0bb0 --- /dev/null +++ b/hscontrol/web.go @@ -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) + })) +} diff --git a/hscontrol/web_test.go b/hscontrol/web_test.go new file mode 100644 index 00000000..716a75ec --- /dev/null +++ b/hscontrol/web_test.go @@ -0,0 +1,109 @@ +package hscontrol + +import ( + _ "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func createTestFiles(t *testing.T, root string, files map[string]string) { + t.Helper() + for path, content := range files { + fullPath := filepath.Join(root, path) + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + } +} + +func TestFileHandler(t *testing.T) { + root := t.TempDir() + + files := map[string]string{ + "file.txt": "hello file", + "dir/index.html": "dir index", + "index.html": "root index", + } + + createTestFiles(t, root, files) + + tests := []struct { + name string + urlPath string + uri string + spa bool + wantStatus int + wantBody string + }{ + { + name: "existing file", + urlPath: "/", + uri: "/file.txt", + spa: false, + wantStatus: 200, + wantBody: "hello file", + }, + { + name: "directory with index.html", + urlPath: "/", + uri: "/dir/", + spa: false, + wantStatus: 200, + wantBody: "dir index", + }, + { + name: "nonexistent file with SPA", + urlPath: "/", + uri: "/notfound", + spa: true, + wantStatus: 200, + wantBody: "root index", + }, + { + name: "nonexistent file without SPA", + urlPath: "/", + uri: "/notfound", + spa: false, + wantStatus: 404, + wantBody: "", + }, + { + name: "non GET/HEAD method", + urlPath: "/", + uri: "/file.txt", + spa: false, + wantStatus: 404, + wantBody: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := fileHandler(tt.urlPath, root, tt.spa) + method := http.MethodGet + if strings.Contains(tt.name, "non GET/HEAD") { + method = http.MethodPost + } + req := httptest.NewRequest(method, tt.uri, nil) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != tt.wantStatus { + t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code) + } + + if tt.wantBody != "" && !strings.Contains(rr.Body.String(), tt.wantBody) { + t.Errorf("expected body %q, got %q", tt.wantBody, rr.Body.String()) + } + }) + } +} From e6330b8b30e0b0d36a2cb9fc44a174fae3d372f0 Mon Sep 17 00:00:00 2001 From: lujian Date: Mon, 22 Dec 2025 11:29:06 +0800 Subject: [PATCH 2/5] feat(config): fix web config path - Add missing `apps` layer under `web` for multiple web apps --- config-example.yaml | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/config-example.yaml b/config-example.yaml index d07caf60..23604b01 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -439,21 +439,23 @@ taildrop: web: # If disabled, no static files will be served at all. enabled: true - # 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 + # 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 From 69de63bab39486c14cab403f44a4199d31383c62 Mon Sep 17 00:00:00 2001 From: lujian Date: Mon, 22 Dec 2025 11:41:49 +0800 Subject: [PATCH 3/5] docs(web): Add web interface integration document --- docs/ref/integration/web-ui.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/ref/integration/web-ui.md b/docs/ref/integration/web-ui.md index 3088a87d..45c139b6 100644 --- a/docs/ref/integration/web-ui.md +++ b/docs/ref/integration/web-ui.md @@ -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 From e3d175b534e91d33c38eb95cf5845da09cde3139 Mon Sep 17 00:00:00 2001 From: lujian Date: Mon, 22 Dec 2025 12:58:15 +0800 Subject: [PATCH 4/5] test(web): optimize web app unit tests - Consolidate SPA and non-SPA scenarios in a single table-driven test - Cover directory access with and without index.html - Include access to files under root and subdirectories - Ensure URL path prefix is handled correctly - Document default 301 redirect behavior for /*/index.html access --- hscontrol/web_test.go | 150 +++++++++++++++++++----------------------- 1 file changed, 69 insertions(+), 81 deletions(-) diff --git a/hscontrol/web_test.go b/hscontrol/web_test.go index 716a75ec..5a815c30 100644 --- a/hscontrol/web_test.go +++ b/hscontrol/web_test.go @@ -1,108 +1,96 @@ package hscontrol import ( - _ "io/fs" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" + + "github.com/gorilla/mux" + "github.com/juanfont/headscale/hscontrol/types" ) -func createTestFiles(t *testing.T, root string, files map[string]string) { - t.Helper() - for path, content := range files { - fullPath := filepath.Join(root, path) - dir := filepath.Dir(fullPath) - if err := os.MkdirAll(dir, 0755); err != nil { - t.Fatalf("failed to create dir: %v", err) - } - if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { - t.Fatalf("failed to write file: %v", err) - } - } -} +func TestRegisterWebApps(t *testing.T) { + tmpDir := t.TempDir() -func TestFileHandler(t *testing.T) { - root := 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) - files := map[string]string{ - "file.txt": "hello file", - "dir/index.html": "dir index", - "index.html": "root index", - } + // /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) - createTestFiles(t, root, files) + // /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 { - name string - urlPath string - uri string - spa bool - wantStatus int - wantBody string + path string + expected string + status int + spa bool }{ - { - name: "existing file", - urlPath: "/", - uri: "/file.txt", - spa: false, - wantStatus: 200, - wantBody: "hello file", - }, - { - name: "directory with index.html", - urlPath: "/", - uri: "/dir/", - spa: false, - wantStatus: 200, - wantBody: "dir index", - }, - { - name: "nonexistent file with SPA", - urlPath: "/", - uri: "/notfound", - spa: true, - wantStatus: 200, - wantBody: "root index", - }, - { - name: "nonexistent file without SPA", - urlPath: "/", - uri: "/notfound", - spa: false, - wantStatus: 404, - wantBody: "", - }, - { - name: "non GET/HEAD method", - urlPath: "/", - uri: "/file.txt", - spa: false, - wantStatus: 404, - wantBody: "", - }, + // 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.name, func(t *testing.T) { - handler := fileHandler(tt.urlPath, root, tt.spa) - method := http.MethodGet - if strings.Contains(tt.name, "non GET/HEAD") { - method = http.MethodPost + 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, + }, + }, } - req := httptest.NewRequest(method, tt.uri, nil) + RegisterWebApps(router, cfg) + + req := httptest.NewRequest(http.MethodGet, tt.path, nil) rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) - handler.ServeHTTP(rr, req) - - if rr.Code != tt.wantStatus { - t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code) + // 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()) } - - if tt.wantBody != "" && !strings.Contains(rr.Body.String(), tt.wantBody) { - t.Errorf("expected body %q, got %q", tt.wantBody, rr.Body.String()) + // 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()) } }) } From 088a6353f2bd39583f391ee7e3de4493b926aa7f Mon Sep 17 00:00:00 2001 From: lujian Date: Mon, 22 Dec 2025 15:28:49 +0800 Subject: [PATCH 5/5] chore(changelog): add entry for built-in static web interfaces --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ef22ff2..952b58a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,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)