Skip to content

Commit 614a942

Browse files
authored
Add Tailscale webui (#9)
This PR serves Tailscale's [web interface](https://tailscale.com/kb/1325/device-web-interface) as part of the device-admin app, to provide slightly more info about the machine's Tailscale connectivity (useful for troubleshooting). This work is tracked on Notion at https://www.notion.so/Provide-a-GUI-for-inspecting-Tailscale-status-2884e612c78a80dca83ee33a9fbd39ae?source=copy_link
1 parent fc8924a commit 614a942

File tree

9 files changed

+154
-72
lines changed

9 files changed

+154
-72
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ require (
440440
github.com/stretchr/testify v1.11.1 // indirect
441441
github.com/subosito/gotenv v1.6.0 // indirect
442442
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
443+
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
443444
github.com/tetafro/godot v1.5.4 // indirect
444445
github.com/theupdateframework/go-tuf v0.7.0 // indirect
445446
github.com/theupdateframework/go-tuf/v2 v2.0.2 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,6 +1191,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
11911191
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
11921192
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
11931193
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
1194+
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
1195+
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
11941196
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
11951197
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
11961198
github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA=

internal/app/deviceadmin/routes/remote/routes.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"net/http"
88
"net/netip"
9+
"strings"
910

1011
"github.com/labstack/echo/v4"
1112
"github.com/pkg/errors"
@@ -26,10 +27,32 @@ func New(r godest.TemplateRenderer, tsc *ts.Client) *Handlers {
2627
}
2728
}
2829

29-
func (h *Handlers) Register(er godest.EchoRouter) {
30+
func (h *Handlers) Register(er godest.EchoRouter) error {
3031
er.GET(h.r.BasePath+"remote", h.HandleRemoteGet())
3132
// assistance
3233
er.POST(h.r.BasePath+"remote/assistance", h.HandleAssistancePost())
34+
// tailscale
35+
tsws, err := h.tsc.InitWebServer(h.r.BasePath + "remote/tailscale")
36+
if err != nil {
37+
return err
38+
}
39+
er.GET(h.r.BasePath+"remote/tailscale/", echo.WrapHandler(tsws))
40+
er.GET(h.r.BasePath+"remote/tailscale/*", echo.WrapHandler(tsws))
41+
er.POST(h.r.BasePath+"remote/tailscale/*", echo.WrapHandler(tsws))
42+
er.PATCH(h.r.BasePath+"remote/tailscale/*", echo.WrapHandler(tsws))
43+
return nil
44+
}
45+
46+
func (h *Handlers) TrailingSlashSkipper(c echo.Context) bool {
47+
// Tailscale's web UI page assumes that the web page has a trailing slash for loading its JS and
48+
// CSS assets from relative paths:
49+
return c.Request().URL.Path == h.r.BasePath+"remote/tailscale/"
50+
}
51+
52+
func (h *Handlers) GzipSkipper(c echo.Context) bool {
53+
// We skip gzip for the Tailscale web GUI because its HTTP handler already does its own gzip
54+
// compression, and HTTP clients assume that responses don't have two or more layers of gzipping:
55+
return strings.HasPrefix(c.Request().URL.Path, h.r.BasePath+"remote/tailscale/")
3356
}
3457

3558
type RemoteViewData struct {

internal/app/deviceadmin/routes/routes.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
package routes
33

44
import (
5+
"github.com/labstack/echo/v4"
6+
"github.com/pkg/errors"
57
"github.com/sargassum-world/godest"
68

79
"github.com/openUC2/device-admin/internal/app/deviceadmin/client"
@@ -16,6 +18,8 @@ import (
1618
type Handlers struct {
1719
r godest.TemplateRenderer
1820
globals *client.Globals
21+
22+
remote *remote.Handlers
1923
}
2024

2125
func New(r godest.TemplateRenderer, globals *client.Globals) *Handlers {
@@ -25,12 +29,24 @@ func New(r godest.TemplateRenderer, globals *client.Globals) *Handlers {
2529
}
2630
}
2731

28-
func (h *Handlers) Register(er godest.EchoRouter, em godest.Embeds) {
32+
func (h *Handlers) Register(er godest.EchoRouter, em godest.Embeds) error {
2933
assets.RegisterStatic(h.r.BasePath, er, em)
3034
assets.NewTemplated(h.r).Register(er)
3135
home.New(h.r).Register(er)
3236
identity.New(h.r).Register(er)
3337
internet.New(h.r, h.globals.NetworkManager).Register(er)
34-
remote.New(h.r, h.globals.Tailscale).Register(er)
38+
h.remote = remote.New(h.r, h.globals.Tailscale)
39+
if err := h.remote.Register(er); err != nil {
40+
return errors.Wrap(err, "couldn't register handlers for remote routes")
41+
}
3542
osconfig.New(h.r).Register(er)
43+
return nil
44+
}
45+
46+
func (h *Handlers) TrailingSlashSkipper(c echo.Context) bool {
47+
return h.remote.TrailingSlashSkipper(c)
48+
}
49+
50+
func (h *Handlers) GzipSkipper(c echo.Context) bool {
51+
return h.remote.GzipSkipper(c)
3652
}

internal/app/deviceadmin/server.go

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,20 @@ func (s *Server) configureHeaders(e *echo.Echo) error {
9292
cspBuilder := cspbuilder.Builder{
9393
Directives: map[string][]string{
9494
cspbuilder.DefaultSrc: {"'self'"},
95-
cspbuilder.ScriptSrc: append(
96-
// Warning: script-src 'self' may not be safe to use if we're hosting user-uploaded content.
97-
// Then we'll need to provide hashes for scripts & styles we include by URL, and we'll need
98-
// to add the SRI integrity attribute to the tags including those files; however, it's
99-
// unclear how well-supported they are by browsers.
100-
[]string{"'self'", "'unsafe-inline'"},
101-
s.Inlines.ComputeJSHashesForCSP()...,
102-
),
95+
// Note: the following is needed for the Tailscale web GUI to check device status (but the GUI
96+
// still works without this permission, it just doesn't report device status):
97+
cspbuilder.ConnectSrc: {"*"},
98+
// Note: script-src "unsafe-inline" (which is ignored if we provide one or more hashes for
99+
// CSP) is needed by the Tailscale web GUI:
100+
cspbuilder.ScriptSrc: {"'self'", "'unsafe-inline'"},
101+
// cspbuilder.ScriptSrc: append(
102+
// // Warning: script-src 'self' may not be safe to use if we're hosting user-uploaded content.
103+
// // Then we'll need to provide hashes for scripts & styles we include by URL, and we'll need
104+
// // to add the SRI integrity attribute to the tags including those files; however, it's
105+
// // unclear how well-supported they are by browsers.
106+
// []string{"'self'", "'unsafe-inline'"},
107+
// s.Inlines.ComputeJSHashesForCSP()...,
108+
// ),
103109
cspbuilder.StyleSrc: append(
104110
[]string{
105111
"'self'",
@@ -108,9 +114,10 @@ func (s *Server) configureHeaders(e *echo.Echo) error {
108114
},
109115
s.Inlines.ComputeCSSHashesForCSP()...,
110116
),
111-
cspbuilder.ObjectSrc: {"'none'"},
112-
cspbuilder.ChildSrc: {"'self'"},
113-
cspbuilder.ImgSrc: {"*"},
117+
cspbuilder.ObjectSrc: {"'none'"},
118+
cspbuilder.ChildSrc: {"'self'"},
119+
// Note: img-src with scheme "data:" is needed by the Tailscale web GUI:
120+
cspbuilder.ImgSrc: {"*", "data:"},
114121
cspbuilder.BaseURI: {"'none'"},
115122
cspbuilder.FormAction: {"'self'"},
116123
cspbuilder.FrameAncestors: {"'none'"},
@@ -145,17 +152,23 @@ func (s *Server) Register(e *echo.Echo) error {
145152
// Compression Middleware
146153
e.Use(middleware.Decompress())
147154
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
148-
Level: s.Globals.Config.HTTP.GzipLevel,
155+
Level: s.Globals.Config.HTTP.GzipLevel,
156+
Skipper: s.Handlers.GzipSkipper,
149157
}))
150158

151159
// Other Middleware
152-
e.Pre(middleware.RemoveTrailingSlash())
153-
e.Use(gmw.RequireContentTypes(echo.MIMEApplicationForm))
160+
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
161+
Skipper: s.Handlers.TrailingSlashSkipper,
162+
}))
163+
// application/JSON is needed by the Tailscale web GUI:
164+
e.Use(gmw.RequireContentTypes(echo.MIMEApplicationForm, echo.MIMEApplicationJSON))
154165
// TODO: enable Prometheus and rate-limiting
155166

156167
// Handlers
157168
e.HTTPErrorHandler = NewHTTPErrorHandler(s.Renderer, s.Embeds.TemplatesFS)
158-
s.Handlers.Register(e, s.Embeds)
169+
if err := s.Handlers.Register(e, s.Embeds); err != nil {
170+
return errors.Wrap(err, "couldn't register HTTP route handlers")
171+
}
159172

160173
return nil
161174
}
@@ -185,6 +198,7 @@ func (s *Server) Shutdown(ctx context.Context, e *echo.Echo) (err error) {
185198
// FIXME: e.Shutdown calls e.Server.Shutdown, which doesn't wait for WebSocket connections. When
186199
// starting Echo, we need to call e.Server.RegisterOnShutdown with a function to gracefully close
187200
// WebSocket connections!
201+
s.Globals.Tailscale.Shutdown()
188202
if errEcho := e.Shutdown(ctx); errEcho != nil {
189203
s.Globals.Base.Logger.Error(errors.Wrap(errEcho, "couldn't shut down http server"))
190204
err = errEcho

internal/clients/tailscale/client.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@ import (
88
"github.com/pkg/errors"
99
"github.com/sargassum-world/godest"
1010
tcl "tailscale.com/client/local"
11+
tsw "tailscale.com/client/web"
1112
"tailscale.com/ipn"
1213
"tailscale.com/ipn/ipnstate"
1314
)
1415

1516
type Client struct {
1617
Config Config
1718

18-
ts *tcl.Client
19-
l godest.Logger
19+
ts *tcl.Client
20+
tsws *tsw.Server
21+
l godest.Logger
2022
}
2123

2224
func NewClient(c Config, l godest.Logger) *Client {
@@ -28,6 +30,25 @@ func NewClient(c Config, l godest.Logger) *Client {
2830
}
2931
}
3032

33+
func (c *Client) InitWebServer(basePath string) (tsws *tsw.Server, err error) {
34+
if c.tsws, err = tsw.NewServer(tsw.ServerOpts{
35+
Mode: tsw.LoginServerMode,
36+
CGIMode: true,
37+
PathPrefix: basePath,
38+
LocalClient: c.ts,
39+
Logf: c.l.Printf,
40+
}); err != nil {
41+
return nil, errors.Wrap(err, "couldn't initialize server for Tailscale web GUI")
42+
}
43+
return c.tsws, nil
44+
}
45+
46+
func (c *Client) Shutdown() {
47+
if c.tsws != nil {
48+
c.tsws.Shutdown()
49+
}
50+
}
51+
3152
func (c *Client) Provision(ctx context.Context, deviceAuthKey string) error {
3253
prefs, err := c.ts.GetPrefs(ctx)
3354
if err != nil {

web/templates/internet/external-network-form.partial.tmpl

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
{{$connProfile := (get . "ConnProfile")}}
2-
{{$availableSSIDs := (get . "AvailableSSIDs")}}
32
{{$Meta := (get . "Meta")}}
43

54
{{$conn := $connProfile.Settings.Conn}}
@@ -27,16 +26,6 @@
2726
required
2827
size=30
2928
>
30-
<turbo-frame id="{{$Meta.BasePath}}internet/devices/wlan0/access-points">
31-
<datalist id="{{$Meta.BasePath}}internet/devices/wlan0/access-points/list">
32-
{{range $ssid := $availableSSIDs}}
33-
{{if not $ssid}}
34-
{{continue}}
35-
{{end}}
36-
<option value="{{$ssid}}"></option>
37-
{{end}}
38-
</datalist>
39-
</turbo-frame>
4029
</div>
4130
</div>
4231

web/templates/internet/index.page.tmpl

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
<section class="section content">
1616
<h1>Internet Access</h1>
17-
<p>TODO: display an overview of internet connectivity from NetworkManager</p>
17+
<!-- <p>TODO: display an overview of internet connectivity from NetworkManager</p> -->
1818
<article class="message is-info two-card-width">
1919
<div class="message-body">
2020
This is a simplified view of your internet settings. If you're an experienced Linux system
@@ -52,14 +52,16 @@
5252
"Use external network now" button further down this page.
5353
</div>
5454
</article>
55-
<p>
56-
TODO: we could simplify this behavior by making a system service which checks whether the
57-
wlan0-internet profile's autoconnect behavior is enabled and, if so, deactivates
58-
wlan0-hotspot and activates wlan0-internet instead whenever the wlan0-internet's network
59-
is within range. This would effectively be a "always automatically try to connect"
60-
behavior, instead of the "autoconnect only during boot" behavior which we currently have
61-
with NetworkManager.
62-
</p>
55+
<!--
56+
<p>
57+
TODO: simplify this behavior by making a system service which checks whether the
58+
wlan0-internet profile's autoconnect behavior is enabled and, if so, deactivate
59+
wlan0-hotspot and activate wlan0-internet instead whenever the wlan0-internet's network
60+
is within range. This would effectively be a "always automatically try to connect"
61+
behavior, instead of the "autoconnect only during boot" behavior which we currently have
62+
with NetworkManager.
63+
</p>
64+
-->
6365
<div class="card section-card">
6466
<div class="card-content">
6567
<turbo-frame id="{{.Meta.BasePath}}internet/devices/wlan0/access-points">
@@ -72,10 +74,6 @@
7274
data-turbo-frame="{{.Meta.BasePath}}internet/devices/wlan0/access-points"
7375
class="is-inline-block mb-3"
7476
>
75-
<!--
76-
FIXME: for some reason data-turbo-frame isn't working correctly. Maybe we can
77-
instead use Turbo Streams to update the list of networks instead?
78-
-->
7977
<input type="hidden" name="state" value="refreshed">
8078
<input type="hidden" name="redirect-target" value="{{.Meta.Path}}">
8179
<div class="field">
@@ -89,13 +87,20 @@
8987
</div>
9088
</div>
9189
</form>
92-
{{
93-
template "internet/external-network-form.partial.tmpl" dict
94-
"ConnProfile" .Data.Wlan0InternetConnProfile
95-
"AvailableSSIDs" .Data.AvailableSSIDs
96-
"Meta" .Meta
97-
}}
90+
<datalist id="{{.Meta.BasePath}}internet/devices/wlan0/access-points/list">
91+
{{range $ssid := .Data.AvailableSSIDs}}
92+
{{if not $ssid}}
93+
{{continue}}
94+
{{end}}
95+
<option value="{{$ssid}}"></option>
96+
{{end}}
97+
</datalist>
9898
</turbo-frame>
99+
{{
100+
template "internet/external-network-form.partial.tmpl" dict
101+
"ConnProfile" .Data.Wlan0InternetConnProfile
102+
"Meta" .Meta
103+
}}
99104
</div>
100105
</div>
101106

0 commit comments

Comments
 (0)