Skip to content

Commit

Permalink
server: add a RoutePrefix option
Browse files Browse the repository at this point in the history
In some cases it is useful to run TailSQL on a server that already has other
endpoints. To support this, add a RoutePrefix option: When this is set, it will
be prepended to the the UI, API, and static file routes handled by the tailsql
server.

- Add tests to exercise prefixed routes.
- Plumb the prefix into the UI template and use it for static files.
- Fix an absolute path in the style sheet.
- Fix an absolute path in the CSV download script.
- Log a prefixed route in the example CLI.
  • Loading branch information
creachadair committed Jul 16, 2024
1 parent edba12f commit b71dd18
Show file tree
Hide file tree
Showing 8 changed files with 84 additions and 21 deletions.
2 changes: 1 addition & 1 deletion cmd/tailsql/tailsql.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func runLocalService(ctx context.Context, opts tailsql.Options, port int) error
hsrv.Shutdown(context.Background()) // ctx is already terminated
tsql.Close()
}()
log.Printf("Starting local tailsql at http://%s", hsrv.Addr)
log.Printf("Starting local tailsql at http://%s", hsrv.Addr+opts.RoutePrefix)
if err := hsrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
ctrl.Fatalf(err.Error())
}
Expand Down
13 changes: 13 additions & 0 deletions server/tailsql/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ type Options struct {
// Additional links that should be propagated to the UI.
UILinks []UILink `json:"links,omitempty"`

// If set, prepend this prefix to each HTTP route. By default, routes are
// anchored at "/".
RoutePrefix string `json:"routePrefix,omitempty"`

// The maximum timeout for a database query (0 means no timeout).
QueryTimeout Duration `json:"queryTimeout,omitempty"`

Expand Down Expand Up @@ -211,6 +215,15 @@ func (o Options) localState() (*localState, error) {
return newLocalState(db)
}

func (o Options) routePrefix() string {
if o.RoutePrefix != "" {
// Routes are anchored at "/" by default, so remove a trailing "/" if
// there is one. E.g., "/foo/" beomes "/foo", and "/" becomes "".
return strings.TrimSuffix(o.RoutePrefix, "/")
}
return ""
}

func (o Options) logf() logger.Logf {
if o.Logf == nil {
return log.Printf
Expand Down
4 changes: 2 additions & 2 deletions server/tailsql/static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Params, Area, Cycle, Loop } from './sprite.js';
const dlButton = document.getElementById("dl-button");
const qform = document.getElementById('qform');
const output = document.getElementById('output');
const origin = document.location.origin;
const base = document.location.origin + document.location.pathname;
const sources = document.getElementById('sources');
const body = document.getElementById('tsql');

Expand Down Expand Up @@ -100,8 +100,8 @@ import { Params, Area, Cycle, Loop } from './sprite.js';
dlButton.addEventListener("click", (evt) => {
var fd = new FormData(qform);
var sp = new URLSearchParams(fd);
var href = base + "csv?" + sp.toString();

var href = origin + "/csv?" + sp.toString();
performDownload('query.csv', href);
});

Expand Down
4 changes: 2 additions & 2 deletions server/tailsql/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ div.action {
.action .ctrl:before {
display: none;
}
.action .ctrl:-webkit-details-marker {
.action .ctrl::-webkit-details-marker {
display: none;
}

Expand All @@ -195,6 +195,6 @@ div.action {
position: relative;
height: 32px;
width: 32px;
background: url('/static/nut.png') 0px 0px;
background: url('nut.png') 0px 0px;
visibility: hidden;
}
18 changes: 12 additions & 6 deletions server/tailsql/tailsql.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ type Server struct {
state *localState // local state database (for query logs)
self string // if non-empty, the local state source label
links []UILink
prefix string
rules []UIRewriteRule
authorize func(string, *apitype.WhoIsResponse) error
qcheck func(Query) (Query, error)
Expand Down Expand Up @@ -160,6 +161,7 @@ func NewServer(opts Options) (*Server, error) {
state: state,
self: opts.LocalSource,
links: opts.UILinks,
prefix: opts.routePrefix(),
rules: opts.UIRewriteRules,
authorize: opts.authorize(),
qcheck: opts.checkQuery(),
Expand Down Expand Up @@ -219,8 +221,11 @@ func (s *Server) Close() error {
// NewMux constructs an HTTP router for the service.
func (s *Server) NewMux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/", s.serveUI)
mux.Handle("/static/", http.FileServer(http.FS(staticFS)))
mux.Handle(s.prefix+"/", http.StripPrefix(s.prefix, http.HandlerFunc(s.serveUI)))

// N.B. We have to strip the prefix back off for the static files, since the
// embedded FS thinks it is rooted at "/".
mux.Handle(s.prefix+"/static/", http.StripPrefix(s.prefix, http.FileServer(http.FS(staticFS))))
return mux
}

Expand Down Expand Up @@ -297,10 +302,11 @@ func (s *Server) serveUIInternal(w http.ResponseWriter, r *http.Request, caller

w.Header().Set("Content-Type", "text/html")
data := &uiData{
Query: q.Query,
Source: q.Source,
Sources: s.getHandles(),
Links: s.links,
Query: q.Query,
Source: q.Source,
Sources: s.getHandles(),
Links: s.links,
RoutePrefix: s.prefix,
}
out, err := s.queryContext(r.Context(), caller, q)
if errors.Is(err, errTooManyRows) {
Expand Down
43 changes: 43 additions & 0 deletions server/tailsql/tailsql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,3 +524,46 @@ type sqlDB struct{ *sql.DB }
func (s sqlDB) Query(ctx context.Context, query string, params ...any) (tailsql.RowSet, error) {
return s.DB.QueryContext(ctx, query, params...)
}

func TestRoutePrefix(t *testing.T) {
s, err := tailsql.NewServer(tailsql.Options{
RoutePrefix: "/sub/dir",
})
if err != nil {
t.Fatalf("NewServer: unexpected error: %v", err)
}
defer s.Close()

hs := httptest.NewServer(s.NewMux())
defer hs.Close()
cli := hs.Client()

t.Run("NotFound", func(t *testing.T) {
rsp, err := cli.Get(hs.URL + "/static/logo.svg")
if err != nil {
t.Fatalf("Get: unexpected error: %v", err)
}
_, err = io.ReadAll(rsp.Body)
rsp.Body.Close()
if err != nil {
t.Errorf("Read body: %v", err)
}
if code := rsp.StatusCode; code != http.StatusNotFound {
t.Errorf("Get: got response %v, want %v", code, http.StatusNotFound)
}
})

t.Run("OK", func(t *testing.T) {
txt := string(mustGet(t, cli, hs.URL+"/sub/dir/static/logo.svg"))
if !strings.HasPrefix(txt, "<svg ") {
t.Errorf("Get logo.svg: output missing SVG prefix:\n%s", txt)
}
})

t.Run("UI", func(t *testing.T) {
txt := string(mustGet(t, cli, hs.URL+"/sub/dir"))
if !strings.Contains(txt, "Tailscale SQL Playground") {
t.Errorf("Get UI: missing expected title:\n%s", txt)
}
})
}
8 changes: 4 additions & 4 deletions server/tailsql/ui.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
<html><head>
<title>Tailscale SQL Playground</title>
<meta charset="utf-8" />
<link rel="stylesheet" type="text/css" href="/static/style.css" />
<link rel="icon" href="/static/favicon.ico" />
<link rel="stylesheet" type="text/css" href="{{.RoutePrefix}}/static/style.css" />
<link rel="icon" href="{{.RoutePrefix}}/static/favicon.ico" />
</head><body id="tsql">

<div class="logo">
<img src="/static/logo.svg" width=64 height=64 />
<img src="{{.RoutePrefix}}/static/logo.svg" width=64 height=64 />
<span>TailSQL</span>
</div>

Expand Down Expand Up @@ -56,6 +56,6 @@
</table></div>
{{end -}}

<script type="module" src="/static/script.js"></script>
<script type="module" src="{{.RoutePrefix}}/static/script.js"></script>
</body>
</html>
13 changes: 7 additions & 6 deletions server/tailsql/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ type LocalClient interface {

// uiData is the concrete type of the data value passed to the UI template.
type uiData struct {
Query string // the original query
Sources []*dbHandle // the available databases
Source string // the selected source
Output *dbResult // query results (may be nil)
Error *string // error results (may be nil)
Links []UILink // static UI links
Query string // the original query
Sources []*dbHandle // the available databases
Source string // the selected source
Output *dbResult // query results (may be nil)
Error *string // error results (may be nil)
Links []UILink // static UI links
RoutePrefix string // for links to the API and static files
}

// Version reports the version string of the currently running binary.
Expand Down

0 comments on commit b71dd18

Please sign in to comment.