mirror of
https://github.com/juanfont/headscale.git
synced 2026-01-23 02:24:10 +00:00
Merge 088a6353f2 into 515a22e696
This commit is contained in:
commit
ee014f285d
7 changed files with 266 additions and 1 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
90
hscontrol/web.go
Normal 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
97
hscontrol/web_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue