├── .github └── workflows │ └── go-presubmit.yml ├── LICENSE ├── README.md ├── authorizer ├── aclgrant.go ├── authorizer_test.go └── map.go ├── client └── tailsql │ ├── client.go │ └── client_test.go ├── cmd └── tailsql │ ├── sample.tmpl │ └── tailsql.go ├── go.mod ├── go.sum ├── server └── tailsql │ ├── files │ └── munk.png │ ├── internal_test.go │ ├── local.go │ ├── metrics.go │ ├── options.go │ ├── state-schema.sql │ ├── static │ ├── favicon.ico │ ├── logo.svg │ ├── nut.png │ ├── script.js │ ├── sprite.js │ └── style.css │ ├── tailsql.go │ ├── tailsql_test.go │ ├── testdata │ ├── config.hujson │ ├── fake-test.key │ └── init.sql │ ├── ui.tmpl │ └── utils.go ├── tools ├── deps.go └── update-oss.sh └── uirules ├── uirules.go └── uirules_test.go /.github/workflows/go-presubmit.yml: -------------------------------------------------------------------------------- 1 | name: Go presubmit 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, reopened, synchronize] 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | build: 16 | name: Go presubmit 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | matrix: 20 | go-version: ['stable'] 21 | os: ['ubuntu-latest'] 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Install Go ${{ matrix.go-version }} 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: ${{ matrix.go-version }} 28 | cache: true 29 | - uses: creachadair/go-presubmit-action@v2 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023 Tailscale Inc & AUTHORS. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TailSQL 2 | 3 | [![GoDoc](https://img.shields.io/static/v1?label=godoc&message=reference&color=peru)](https://pkg.go.dev/github.com/tailscale/tailsql) 4 | [![CI](https://github.com/tailscale/tailsql/actions/workflows/go-presubmit.yml/badge.svg?event=push&branch=main)](https://github.com/tailscale/tailsql/actions/workflows/go-presubmit.yml) 5 | 6 | > [!NOTE] 7 | > Please file issues in our [main open-source repository](https://github.com/tailscale/tailscale/issues). 8 | 9 | TailSQL is a self-contained SQL playground service that runs on [Tailscale](https://tailscale.com). 10 | It permits users to query SQL databases from a basic web-based UI, with support for any database 11 | that can plug in to the Go [`database/sql`](https://godoc.org/database/sql) package. 12 | 13 | ## Running Locally 14 | 15 | Run the commands below from a checkout of https://github.com/tailscale/tailsql. 16 | 17 | To run locally, you will need a SQLite database to serve data from. If you do 18 | not already have one, you can create one using the test data for this package: 19 | 20 | ```shell 21 | # Creates test.db in the current working directory. 22 | sqlite3 test.db -init ./server/tailsql/testdata/init.sql .quit 23 | ``` 24 | 25 | Now build the `tailsql` tool, and create a HuJSON (JWCC) configuration file for it: 26 | 27 | ```shell 28 | go build ./cmd/tailsql 29 | 30 | # The --init-config flag generates a stub config pointing to "test.db". 31 | ./tailsql --init-config demo.conf 32 | ``` 33 | 34 | Feel free to edit this configuration file to suit your tastes. The file encodes 35 | an [Options](./server/tailsql/options.go#L27) value. Once you are satisfied, run: 36 | 37 | ```shell 38 | # The --local flag starts an HTTP server on localhost. 39 | ./tailsql --local 8080 --config demo.conf 40 | ``` 41 | 42 | This starts up the server on localhost. Visit the UI at http://localhost:8080, 43 | or call it from the command-line using `curl`: 44 | 45 | ```shell 46 | # Note that you must provide a Sec-Tailsql header with API calls. 47 | 48 | # Fetch output as comma-separated values. 49 | curl -s -H 'sec-tailsql: 1' http://localhost:8080/csv --url-query 'q=select * from users' 50 | 51 | # Fetch output as JSON objects. 52 | curl -s -H 'sec-tailsql: 1' http://localhost:8080/json --url-query 'q=select location, count(*) n from users where location is not null group by location order by n desc' 53 | 54 | # Check the query log. 55 | curl -s -H 'sec-tailsql: 1' http://localhost:8080/json --url-query 'q=select * from query_log' --url-query src=self 56 | ``` 57 | 58 | ## Running on Tailscale 59 | 60 | To run as a Tailscale node, make sure the `"hostname"` field is set in the 61 | configuration file, then run `tailsql` without the `--local` flag. 62 | 63 | Note that the first time you start up a node, you may need to provide a key to 64 | authorize your node on the tailnet, e.g.: 65 | 66 | ```shell 67 | # Get a key from https://login.tailscale.com/admin/settings/keys. 68 | # The first time you start the node, provide the key via the TS_AUTHKEY environment. 69 | TS_AUTHKEY=tskey-XXXX ./tailsql --config demo.conf 70 | 71 | # Note: Omit --local to start on Tailscale. 72 | ``` 73 | 74 | Subsequent runs can omit the key. 75 | 76 | ## Configuration and Extension 77 | 78 | The command-line tool in `cmd/tailsql` implements a standalone SQL playground server with some default options. The [tailsql][tailsql] package is designed to work as a library, however, and for specific use cases you will probably want to build your own server binary. It is also possible to run a TailSQL server "inside" another process, using [tsnet][tsnet]. The example binary includes code you can crib from to suit your own needs. 79 | 80 | The basic workflow to set up a new TailSQL server is: 81 | 82 | 1. Create the server with your desired [`tailsql.Options`][options]: 83 | 84 | ```go 85 | tsql, err := tailsql.NewServer(tailsql.Options{ 86 | // ... 87 | }) 88 | ``` 89 | 90 | 2. Serve the mux via HTTP: 91 | 92 | ```go 93 | http.ListenAndServe("localhost:8080", tsql.NewMux()) 94 | ``` 95 | 96 | Note that the server does not do any authentication or encryption of its own. If you want to share the playground beyond your own local machine, you will need to provide appropriate access controls. One easy way to do this is using Tailscale (which handles encryption, access control, and TLS), but if you prefer you can handle those details separately by providing your own implementation of the [`tailsql.LocalClient` interface][lcintf] and setting the [`Authorize` option][authopt]. 97 | 98 | ### Database Drivers 99 | 100 | By default, only SQLite databases are supported (via https://modernc.org/sqlite). Any driver compatible with the [database/sql][dbsql] package should work, however. To add drivers, add additional imports to the main package (`tailsql.go`) and recompile the program. 101 | 102 | Adding imports: 103 | 104 | ```go 105 | import ( 106 | // ... 107 | 108 | // If you want to support other source types with this tool, you will need 109 | // to import other database drivers below. 110 | 111 | // SQLite driver for database/sql. 112 | _ "modernc.org/sqlite" 113 | 114 | // PostgreSQL driver for database/sql. 115 | _ "github.com/lib/pq" 116 | ) 117 | ``` 118 | 119 | Rebuilding the program: 120 | 121 | ```shell 122 | go build ./cmd/tailsql 123 | ``` 124 | 125 | To configure a database using this driver, populate the `Driver` field of the `DBSpec` in the options: 126 | 127 | ```go 128 | opts := tailsql.Options{ 129 | Sources: []tailsql.DBSpec{{ 130 | Source: "info", // used to select this source in queries (src=info) 131 | Label: "Information", // a human-readable label, shown in the source picker 132 | Driver: "postgres", // must be a driver registered with database/sql 133 | 134 | URL: connectionString, // the connection string for the database 135 | }}, 136 | } 137 | ``` 138 | 139 | Any number of sources can be configured this way. It is also possible to add new data sources dynamically at runtime using the `SetDB` and `SetSource` methods of the server. It is _not_ currently possible to remove data sources once added, however. 140 | 141 | ### Tailscale Integration 142 | 143 | The `Hostname`, `StateDir`, and `ServeHTTPS` options are not interpreted directly by the library, but are provided to make it easier to connect a TailSQL server to [tsnet][tsnet]. The `cmd/tailsql` program shows how these can be used to run the server on a Tailscale node, either with or without TLS support. 144 | 145 | ### Query Logging 146 | 147 | The `LocalState` option permits you to enable logging of successful queries in a separate SQLite database maintained by TailSQL itself. If this option is set, the server will use the specified database to record each query using [`state-schema.sql`][stschema]. If this option is not set, queries are logged only as text (see the `Logf` option). 148 | 149 | In addition, if the `LocalSource` option is set, a read-only view of the the query log database will be included in the list of available data sources, so users can query the log directly in the playground: 150 | 151 | ```sql 152 | -- List the five most-recent successful queries. 153 | select * from query_log order by timestamp desc limit 5; 154 | ``` 155 | 156 | ### Static Links 157 | 158 | The playground UI is defined in [ui.tmpl][uitmpl], and includes an optional section for static links. These are populated from the `UILinks` option. This is a good place to put links to documentation, for example: 159 | 160 | ```go 161 | opts := tailsql.Options{ 162 | UILinks: []tailsql.UILink{ 163 | { 164 | Anchor: "Blog", 165 | URL: "https://tailscale.com/blog", 166 | }, 167 | { 168 | Anchor: "Repo", 169 | URL: "https://github.com/tailscale/tailsql", 170 | }, 171 | }, 172 | } 173 | ``` 174 | 175 | ### Authorization 176 | 177 | By default, the server does not do any authorization. The `LocalClient` option allows you to plug the server in to Tailscale: If this option is set, it is used to resolve callers and only logged-in users will be permitted to make queries. (You could theoretically also implement this interface without Tailscale). 178 | 179 | To further customize authorization, you can provide a callback via the `Authorize` option. The [authorizer][authz] package provides some pre-defined implementations, or you can roll your own. This is useful if you want to expose multiple data sources, some of which have more restrictive access policies. 180 | 181 | ### UI Rewrite Rules 182 | 183 | The server renders column values for the UI as plain strings, using some simple built-in rules for common data types. The `UIRewriteRules` option allows you to extend these rules with custom behaviour. The [uirules][uirules] package provides some pre-defined implementations, or you can roll your own. 184 | 185 | Rewrite rules are applied in the order they are listed in the options. As each value is rendered, the rules are checked: The first rule that **matches** the column value is **applied** to that value, and the result replaces the default. 186 | 187 | For example: 188 | 189 | ```go 190 | blotSecrets := tailsql.UIRewriteRule{ 191 | // If the column name contains "password" or "passwd" ... 192 | Column: regexp.MustCompile(`(?i)passw(or)?d`), 193 | 194 | // Replace the input text with a redaction marker. 195 | Apply: func(column, input string, match []string) any { 196 | return "" 197 | }, 198 | } 199 | ``` 200 | 201 | Only the first matching rule is applied; subsequent rules are skipped. 202 | 203 | 204 | 205 | [authopt]: https://godoc.org/github.com/tailscale/tailsql/server/tailsql#Options.Authorize 206 | [authz]: https://godoc.org/github.com/tailscale/tailsql/authorizer 207 | [dbsql]: https://godoc.org/database/sql 208 | [lcintf]: https://godoc.org/github.com/tailscale/tailsql/server/tailsql#LocalClient 209 | [options]: https://godoc.org/github.com/tailscale/tailsql/server/tailsql#Options 210 | [stschema]: ./server/tailsql/state-schema.sql 211 | [tailsql]: https://godoc.org/github.com/tailscale/tailsql/server/tailsql 212 | [tsnet]: https://godoc.org/tailscale.com/tsnet 213 | [uirules]: https://godoc.org/github.com/tailscale/tailsql/uirules 214 | [uitmpl]: ./server/tailsql/ui.tmpl 215 | -------------------------------------------------------------------------------- /authorizer/aclgrant.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package authorizer 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "log" 10 | 11 | "tailscale.com/client/tailscale/apitype" 12 | "tailscale.com/tailcfg" 13 | "tailscale.com/types/logger" 14 | ) 15 | 16 | // tailsqlCap is the default name of the tailsql capability. 17 | const tailsqlCap = "tailscale.com/cap/tailsql" 18 | 19 | // ACLGrants returns an authorization function that uses ACL grants from the 20 | // tailnet to check access for query sources. 21 | // If logf == nil, logs are sent to log.Printf. 22 | func ACLGrants(logf logger.Logf) func(string, *apitype.WhoIsResponse) error { 23 | if logf == nil { 24 | logf = log.Printf 25 | } 26 | return func(dataSrc string, who *apitype.WhoIsResponse) (err error) { 27 | caller := who.UserProfile.LoginName 28 | if who.Node.IsTagged() { 29 | caller = who.Node.Name 30 | } 31 | defer func() { 32 | logf("[tailsql] auth src=%q who=%q err=%v", dataSrc, caller, err) 33 | }() 34 | type rule struct { 35 | DataSrc []string `json:"src"` 36 | } 37 | rules, err := tailcfg.UnmarshalCapJSON[rule](who.CapMap, tailsqlCap) 38 | if err != nil || len(rules) == 0 { 39 | return errors.New("not authorized for access to tailsql") 40 | } 41 | for _, rule := range rules { 42 | for _, s := range rule.DataSrc { 43 | if s == "*" || s == dataSrc { 44 | return nil 45 | } 46 | } 47 | } 48 | return fmt.Errorf("not authorized for access to %q", dataSrc) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /authorizer/authorizer_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package authorizer_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/tailscale/tailsql/authorizer" 10 | "tailscale.com/client/tailscale/apitype" 11 | "tailscale.com/tailcfg" 12 | ) 13 | 14 | const tailsqlCap = "tailscale.com/cap/tailsql" 15 | 16 | var ( 17 | taggedNode = &apitype.WhoIsResponse{ 18 | Node: &tailcfg.Node{Name: "fake.ts.net", Tags: []string{"tag:special"}}, 19 | UserProfile: &tailcfg.UserProfile{ 20 | ID: 1, LoginName: "user@example.com", DisplayName: "Some P. User", 21 | }, 22 | CapMap: tailcfg.PeerCapMap{ 23 | tailsqlCap: []tailcfg.RawMessage{ 24 | `{"src":["main","alt"]}`, 25 | }, 26 | }, 27 | } 28 | loggedInUser = &apitype.WhoIsResponse{ 29 | Node: &tailcfg.Node{Name: "fake.ts.net"}, 30 | UserProfile: &tailcfg.UserProfile{ 31 | ID: 1, LoginName: "user@example.com", DisplayName: "Some P. User", 32 | }, 33 | CapMap: tailcfg.PeerCapMap{ 34 | tailsqlCap: []tailcfg.RawMessage{ 35 | `{"src":["main"]}`, 36 | }, 37 | }, 38 | } 39 | ) 40 | 41 | func TestACLGrants(t *testing.T) { 42 | auth := authorizer.ACLGrants(t.Logf) 43 | tests := []struct { 44 | src string 45 | rsp *apitype.WhoIsResponse 46 | ok bool 47 | }{ 48 | {"main", taggedNode, true}, 49 | {"alt", taggedNode, true}, 50 | {"other", taggedNode, false}, 51 | {"main", loggedInUser, true}, 52 | {"alt", loggedInUser, false}, 53 | {"other", loggedInUser, false}, 54 | } 55 | 56 | for _, tc := range tests { 57 | err := auth(tc.src, tc.rsp) 58 | if tc.ok && err != nil { 59 | t.Errorf("Authorize %q: unexpected error: %v", tc.src, err) 60 | } else if !tc.ok && err == nil { 61 | t.Errorf("Authorize %q: got nil, want error", tc.src) 62 | } 63 | } 64 | } 65 | 66 | func TestMap(t *testing.T) { 67 | auth := authorizer.Map{ 68 | "main": []string{"user@example.com"}, // access by this user 69 | "other": []string{"other@example.com"}, // access by some other user 70 | "none": nil, // no access by anyone 71 | }.Authorize(t.Logf) 72 | tests := []struct { 73 | src string 74 | rsp *apitype.WhoIsResponse 75 | ok bool 76 | }{ 77 | // Tagged nodes should not get any access. 78 | {"main", taggedNode, false}, 79 | {"alt", taggedNode, false}, 80 | {"other", taggedNode, false}, 81 | 82 | {"main", loggedInUser, true}, // explicitly granted 83 | {"alt", loggedInUser, true}, // by default 84 | {"other", loggedInUser, false}, // this user is not on the list 85 | {"none", loggedInUser, false}, // empty access list 86 | } 87 | 88 | for _, tc := range tests { 89 | err := auth(tc.src, tc.rsp) 90 | if tc.ok && err != nil { 91 | t.Errorf("Authorize %q: unexpected error: %v", tc.src, err) 92 | } else if !tc.ok && err == nil { 93 | t.Errorf("Authorize %q: got nil, want error", tc.src) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /authorizer/map.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // Package authorizer implements access control helpers for tailsql. 5 | package authorizer 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | 11 | "tailscale.com/client/tailscale/apitype" 12 | "tailscale.com/types/logger" 13 | ) 14 | 15 | // A Map maps source labels to lists of usernames who are granted access to 16 | // issue queries against that source. 17 | type Map map[string][]string 18 | 19 | // Authorize returns an authorization function suitable for tailsql.Options. 20 | // 21 | // If a source label is not present in the map, all logged-in users are 22 | // permitted to query the source. If a source label is present in the map, 23 | // only logged-in users in the list are permitted to query the source. Tagged 24 | // nodes are not permitted to query any source. 25 | // 26 | // If logf == nil, logs are sent to log.Printf. 27 | func (m Map) Authorize(logf logger.Logf) func(string, *apitype.WhoIsResponse) error { 28 | if logf == nil { 29 | logf = log.Printf 30 | } 31 | return func(src string, who *apitype.WhoIsResponse) (err error) { 32 | caller := who.UserProfile.LoginName 33 | if who.Node.IsTagged() { 34 | caller = who.Node.Name 35 | } 36 | defer func() { 37 | logf("[tailsql] auth src=%q who=%q err=%v", src, caller, err) 38 | }() 39 | if who.Node.IsTagged() { 40 | return fmt.Errorf("tagged nodes cannot query %q", src) 41 | } 42 | users, ok := m[src] 43 | if !ok { 44 | return nil // no restriction on this source 45 | } 46 | for _, u := range users { 47 | if u == caller { 48 | return nil // this user is permitted access 49 | } 50 | } 51 | return fmt.Errorf("not authorized for access to %q", src) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/tailsql/client.go: -------------------------------------------------------------------------------- 1 | // Package tailsql implements a basic client for the TailSQL service. 2 | package tailsql 3 | 4 | import ( 5 | "context" 6 | "encoding/csv" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "net/url" 13 | "strings" 14 | 15 | "github.com/klauspost/compress/zstd" 16 | server "github.com/tailscale/tailsql/server/tailsql" 17 | ) 18 | 19 | // A Client is a client for the TailSQL HTTP API. 20 | type Client struct { 21 | // Server is the base URL of the server hosting the API (required). 22 | Server string 23 | 24 | // DoHTTP implements the equivalent of http.Client.Do. 25 | // If nil, http.DefaultClient.Do is used. 26 | DoHTTP func(*http.Request) (*http.Response, error) 27 | } 28 | 29 | // ServerInfo is a record of server information reported by the TailSQL meta API. 30 | type ServerInfo struct { 31 | // Sources are the named data sources supported by the server. 32 | Sources []server.DBSpec `json:"sources,omitempty"` 33 | 34 | // Links are links exposed in the UI> 35 | Links []server.UILink `json:"links,omitempty"` 36 | 37 | // QueryTimeout is the maximimum runtime allowed for a single query. 38 | QueryTimeout server.Duration `json:"queryTimeout,omitempty"` 39 | } 40 | 41 | // ServerInfo returns metadata about the server. 42 | func (c Client) ServerInfo(ctx context.Context) (*ServerInfo, error) { 43 | rc, err := callGET(ctx, c.DoHTTP, c.Server+"/meta", nil) 44 | if err != nil { 45 | return nil, err 46 | } 47 | defer rc.Close() 48 | 49 | var meta struct { 50 | Data *ServerInfo `json:"meta"` 51 | } 52 | if err := json.NewDecoder(rc).Decode(&meta); err != nil { 53 | return nil, err 54 | } 55 | return meta.Data, nil 56 | } 57 | 58 | // QueryJSON invokes an SQL query against the specified dataSrc and returns 59 | // matching rows as a slice of the specified type T. A pointer to T must be a 60 | // valid input to json.Unmarshal. 61 | func QueryJSON[T any](ctx context.Context, c Client, dataSrc, sql string) ([]T, error) { 62 | rc, err := callGET(ctx, c.DoHTTP, c.Server+"/json", url.Values{ 63 | "src": {dataSrc}, 64 | "q": {sql}, 65 | }) 66 | if err != nil { 67 | return nil, err 68 | } 69 | defer rc.Close() 70 | dec := json.NewDecoder(rc) 71 | var out []T 72 | for i := 1; ; i++ { 73 | var row T 74 | if err := dec.Decode(&row); err != nil { 75 | if err == io.EOF { 76 | return out, nil 77 | } 78 | return out, fmt.Errorf("decode row %d: %w", i, err) 79 | } 80 | out = append(out, row) 81 | } 82 | } 83 | 84 | // Rows is the result of a successful Query call. 85 | type Rows struct { 86 | Columns []string // column names 87 | Rows [][]string // rows of columns 88 | } 89 | 90 | // Query invokes an SQL query against the specified dataSrc. 91 | func (c Client) Query(ctx context.Context, dataSrc, sql string) (Rows, error) { 92 | rc, err := callGET(ctx, c.DoHTTP, c.Server+"/csv", url.Values{ 93 | "src": {dataSrc}, 94 | "q": {sql}, 95 | }) 96 | if err != nil { 97 | return Rows{}, err 98 | } 99 | defer rc.Close() 100 | rows, err := csv.NewReader(rc).ReadAll() 101 | if err != nil { 102 | return Rows{}, fmt.Errorf("decoding rows: %w", err) 103 | } else if len(rows) == 0 { 104 | return Rows{}, nil 105 | } 106 | return Rows{ 107 | Columns: rows[0], 108 | Rows: rows[1:], 109 | }, nil 110 | } 111 | 112 | func callGET(ctx context.Context, do func(*http.Request) (*http.Response, error), base string, query url.Values) (io.ReadCloser, error) { 113 | url := base 114 | if len(query) != 0 { 115 | url += "?" + query.Encode() 116 | } 117 | if do == nil { 118 | do = http.DefaultClient.Do 119 | } 120 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 121 | if err != nil { 122 | return nil, fmt.Errorf("new request: %w", err) 123 | } 124 | req.Header.Set("Accept-Encoding", "zstd") 125 | req.Header.Set("Sec-Tailsql", "ok") 126 | rsp, err := do(req) 127 | if err != nil { 128 | return nil, fmt.Errorf("get %q: %w", base, err) 129 | } 130 | if rsp.StatusCode != http.StatusOK { 131 | const maxErrorLen = 500 132 | 133 | body, _ := io.ReadAll(rsp.Body) 134 | rsp.Body.Close() 135 | msg := strings.TrimSpace(strings.ReplaceAll(string(body), "\n", " ")) 136 | if len(msg) > maxErrorLen { 137 | msg = msg[:maxErrorLen-3] + "..." 138 | } 139 | return nil, errors.New(msg) 140 | } 141 | if rsp.Header.Get("Content-Encoding") == "zstd" { 142 | dec, err := zstd.NewReader(rsp.Body) 143 | if err != nil { 144 | rsp.Body.Close() 145 | return nil, fmt.Errorf("new zstd decoder: %w", err) 146 | } 147 | return dec.IOReadCloser(), nil 148 | } 149 | return rsp.Body, nil 150 | } 151 | -------------------------------------------------------------------------------- /client/tailsql/client_test.go: -------------------------------------------------------------------------------- 1 | package tailsql_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "net/http/httptest" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/tailscale/tailsql/client/tailsql" 13 | server "github.com/tailscale/tailsql/server/tailsql" 14 | 15 | _ "modernc.org/sqlite" 16 | ) 17 | 18 | func TestClient(t *testing.T) { 19 | // Set up a small database to exercise queries. 20 | dbPath := "file:" + filepath.Join(t.TempDir(), "test.db") 21 | db, err := sql.Open("sqlite", dbPath) 22 | if err != nil { 23 | t.Fatalf("Open test db: %v", err) 24 | } 25 | t.Cleanup(func() { db.Close() }) 26 | if _, err := db.Exec(`create table test ( id integer, value text )`); err != nil { 27 | t.Fatalf("Create table: %v", err) 28 | } 29 | if _, err := db.Exec(`insert into test (id, value) values 30 | (1, 'apple'), (2, 'pear'), (3, 'plum'), (4, 'cherry')`); err != nil { 31 | t.Fatalf("Insert data: %v", err) 32 | } 33 | 34 | // Start a tailsql server with known values for testing. 35 | s, err := server.NewServer(server.Options{ 36 | UILinks: []server.UILink{ 37 | {Anchor: "anchor", URL: "url"}, 38 | }, 39 | Sources: []server.DBSpec{{ 40 | Source: "main", 41 | Label: "label", 42 | Driver: "sqlite", 43 | Named: map[string]string{ 44 | "foo": "select count(*) n from test", 45 | }, 46 | URL: dbPath, 47 | }}, 48 | QueryTimeout: server.Duration(5 * time.Second), 49 | }) 50 | if err != nil { 51 | t.Fatalf("NewServer: %v", err) 52 | } 53 | defer s.Close() 54 | 55 | hs := httptest.NewServer(s.NewMux()) 56 | defer hs.Close() 57 | 58 | cli := tailsql.Client{Server: hs.URL, DoHTTP: hs.Client().Do} 59 | ctx := context.Background() 60 | 61 | t.Run("ServerInfo", func(t *testing.T) { 62 | si, err := cli.ServerInfo(ctx) 63 | if err != nil { 64 | t.Fatalf("ServerInfo failed: %v", err) 65 | } 66 | if diff := cmp.Diff(si, &tailsql.ServerInfo{ 67 | Sources: []server.DBSpec{ 68 | {Source: "main", Label: "label", Named: map[string]string{ 69 | "foo": "select count(*) n from test", 70 | }}, 71 | }, 72 | Links: []server.UILink{{Anchor: "anchor", URL: "url"}}, 73 | QueryTimeout: server.Duration(5 * time.Second), 74 | }); diff != "" { 75 | t.Errorf("Result (-got, +want):\n%s", diff) 76 | } 77 | }) 78 | 79 | t.Run("QueryJSON", func(t *testing.T) { 80 | type row struct { 81 | ID int `json:"id"` 82 | Value string `json:"value"` 83 | } 84 | 85 | got, err := tailsql.QueryJSON[row](ctx, cli, "main", `select id, value from test order by 1`) 86 | if err != nil { 87 | t.Errorf("QueryJSON failed: %v", err) 88 | } 89 | if diff := cmp.Diff(got, []row{ 90 | {1, "apple"}, {2, "pear"}, {3, "plum"}, {4, "cherry"}, 91 | }); diff != "" { 92 | t.Errorf("Result (-got, +want):\n%s", diff) 93 | } 94 | }) 95 | 96 | t.Run("QueryJSON_Named", func(t *testing.T) { 97 | type row struct { 98 | Count int `json:"n"` 99 | } 100 | got, err := tailsql.QueryJSON[row](ctx, cli, "main", `named:foo`) 101 | if err != nil { 102 | t.Errorf("QueryJSON failed: %v", err) 103 | } 104 | if diff := cmp.Diff(got, []row{{4}}); diff != "" { 105 | t.Errorf("Result (-got, +want):\n%s", diff) 106 | } 107 | }) 108 | 109 | t.Run("Query", func(t *testing.T) { 110 | rows, err := cli.Query(ctx, "main", `select id, value from test order by 1`) 111 | if err != nil { 112 | t.Errorf("Query failed: %v", err) 113 | } 114 | if diff := cmp.Diff(rows, tailsql.Rows{ 115 | Columns: []string{"id", "value"}, 116 | Rows: [][]string{ 117 | {"1", "apple"}, {"2", "pear"}, {"3", "plum"}, {"4", "cherry"}, 118 | }, 119 | }); diff != "" { 120 | t.Errorf("Result (-got, +want):\n%s", diff) 121 | } 122 | }) 123 | 124 | t.Run("Query_Named", func(t *testing.T) { 125 | rows, err := cli.Query(ctx, "main", `named:foo`) 126 | if err != nil { 127 | t.Errorf("Query failed: %v", err) 128 | } 129 | if diff := cmp.Diff(rows, tailsql.Rows{ 130 | Columns: []string{"n"}, 131 | Rows: [][]string{{"4"}}, 132 | }); diff != "" { 133 | t.Errorf("Result (-got, +want):\n%s", diff) 134 | } 135 | }) 136 | 137 | t.Run("QueryError", func(t *testing.T) { 138 | rows, err := cli.Query(ctx, "nonesuch", "select 1") 139 | if err == nil { 140 | t.Errorf("Got %+v, want error", rows) 141 | } else { 142 | t.Logf("Got expected error: %v", err) 143 | } 144 | }) 145 | } 146 | -------------------------------------------------------------------------------- /cmd/tailsql/sample.tmpl: -------------------------------------------------------------------------------- 1 | // Example tailsql configuration file. 2 | // This file is in HuJSON (JWCC) format; plain JSON also works. 3 | { 4 | @{ with .Hostname }@// The tailnet hostname the server should run on. 5 | "hostname": "@{.}@",@{ end }@ 6 | @{ with .LocalState }@ 7 | // If set, a SQLite database URL to use for the query log. 8 | "localState": "@{.}@",@{ end }@ 9 | @{ with .LocalSource }@ 10 | // If localState is defined, export a read-only copy of the local state 11 | // database as a source with this name. 12 | "localSource": "@{.}@",@{ end }@ 13 | @{ if .Sources }@ 14 | // Databases that the server will allow queries against. 15 | "sources": [@{ range .Sources }@ 16 | { 17 | // The name that selects this database in queries (src=name). 18 | "source": "@{ .Source }@", 19 | 20 | // A human-readable description for the database. 21 | "label": "@{ .Label }@", 22 | 23 | // The name of the database/sql driver to use for this source. 24 | "driver": "@{ .Driver }@", 25 | @{ if .Named }@ 26 | // Named queries supported by this database (q=named:x). 27 | "named": {@{ range $name, $query := .Named }@ 28 | "@{ $name }@": "@{ $query }@", 29 | @{ end }@},@{ end }@ 30 | 31 | // The database/sql connection string for the database. 32 | "url": "@{ .URL }@", 33 | }, 34 | @{ end }@],@{ end }@ 35 | @{ if .UILinks }@ 36 | // Additional links to display in the UI. 37 | "links": [@{ range .UILinks }@ 38 | { 39 | "anchor": "@{ .Anchor }@", 40 | "url": "@{ .URL }@", 41 | },@{ end }@ 42 | ],@{ end }@ 43 | } 44 | -------------------------------------------------------------------------------- /cmd/tailsql/tailsql.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // Program tailsql runs a standalone SQL playground. 5 | package main 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "errors" 11 | "expvar" 12 | "flag" 13 | "fmt" 14 | "html/template" 15 | "log" 16 | "net/http" 17 | "os" 18 | "os/signal" 19 | "path/filepath" 20 | "syscall" 21 | 22 | "github.com/creachadair/ctrl" 23 | "github.com/tailscale/tailsql/server/tailsql" 24 | "github.com/tailscale/tailsql/uirules" 25 | "tailscale.com/atomicfile" 26 | "tailscale.com/tsnet" 27 | "tailscale.com/tsweb" 28 | "tailscale.com/types/logger" 29 | 30 | _ "embed" 31 | 32 | // If you want to support other source types with this tool, you will need 33 | // to import other database drivers below. 34 | 35 | // SQLite driver for database/sql. 36 | _ "modernc.org/sqlite" 37 | ) 38 | 39 | var ( 40 | localPort = flag.Int("local", 0, "Local service port") 41 | configPath = flag.String("config", "", "Configuration file (HuJSON, required)") 42 | doDebugLog = flag.Bool("debug", false, "Enable very verbose tsnet debug logging") 43 | initConfig = flag.String("init-config", "", 44 | "Generate a basic configuration file in the given path and exit") 45 | ) 46 | 47 | func init() { 48 | flag.Usage = func() { 49 | fmt.Fprintf(os.Stderr, `Usage: %[1]s [options] --config config.json 50 | %[1]s --init-config demo.json 51 | 52 | Run a TailSQL service with the specified --config file. 53 | 54 | If --local > 0, the service is run on localhost at that port. 55 | Otherwise, the server starts a Tailscale node at the configured hostname. 56 | 57 | When run with --init-config set, %[1]s generates an example configuration file 58 | with defaults suitable for running a local service and then exits. 59 | 60 | Options: 61 | `, filepath.Base(os.Args[0])) 62 | flag.PrintDefaults() 63 | } 64 | } 65 | 66 | func main() { 67 | flag.Parse() 68 | ctrl.Run(func() error { 69 | // Case 1: Generate a semple configuration file and exit. 70 | if *initConfig != "" { 71 | return generateBasicConfig(*initConfig) 72 | } else if *configPath == "" { 73 | ctrl.Fatalf("You must provide a non-empty --config path") 74 | } 75 | 76 | // For all the cases below, we need a valid configuration file. 77 | data, err := os.ReadFile(*configPath) 78 | if err != nil { 79 | ctrl.Fatalf("Reading tailsql config: %v", err) 80 | } 81 | var opts tailsql.Options 82 | if err := tailsql.UnmarshalOptions(data, &opts); err != nil { 83 | ctrl.Fatalf("Parsing tailsql config: %v", err) 84 | } 85 | opts.Metrics = expvar.NewMap("tailsql") 86 | opts.UIRewriteRules = []tailsql.UIRewriteRule{ 87 | uirules.FormatSQLSource, 88 | uirules.FormatJSONText, 89 | uirules.LinkURLText, 90 | } 91 | 92 | ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 93 | defer cancel() 94 | 95 | // Case 2: Run unencrypted on a local port. 96 | if *localPort > 0 { 97 | return runLocalService(ctx, opts, *localPort) 98 | } 99 | 100 | // Case 3: Run on a tailscale node. 101 | if opts.Hostname == "" { 102 | ctrl.Fatalf("You must provide a non-empty Tailscale hostname") 103 | } 104 | return runTailscaleService(ctx, opts) 105 | }) 106 | } 107 | 108 | func runLocalService(ctx context.Context, opts tailsql.Options, port int) error { 109 | tsql, err := tailsql.NewServer(opts) 110 | if err != nil { 111 | ctrl.Fatalf("Creating tailsql server: %v", err) 112 | } 113 | 114 | mux := tsql.NewMux() 115 | tsweb.Debugger(mux) 116 | hsrv := &http.Server{ 117 | Addr: fmt.Sprintf("localhost:%d", port), 118 | Handler: mux, 119 | } 120 | 121 | go func() { 122 | <-ctx.Done() 123 | log.Print("Signal received, stopping") 124 | hsrv.Shutdown(context.Background()) // ctx is already terminated 125 | tsql.Close() 126 | }() 127 | log.Printf("Starting local tailsql at http://%s", hsrv.Addr+opts.RoutePrefix) 128 | if err := hsrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { 129 | ctrl.Fatalf(err.Error()) 130 | } 131 | return nil 132 | } 133 | 134 | func runTailscaleService(ctx context.Context, opts tailsql.Options) error { 135 | tsNode := &tsnet.Server{ 136 | Dir: os.ExpandEnv(opts.StateDir), 137 | Hostname: opts.Hostname, 138 | Logf: logger.Discard, 139 | } 140 | if *doDebugLog { 141 | tsNode.Logf = log.Printf 142 | } 143 | defer tsNode.Close() 144 | 145 | log.Printf("Starting tailscale (hostname=%q)", opts.Hostname) 146 | lc, err := tsNode.LocalClient() 147 | if err != nil { 148 | ctrl.Fatalf("Connect local client: %v", err) 149 | } 150 | opts.LocalClient = lc // for authentication 151 | 152 | // Make sure the Tailscale node starts up. It might not, if it is a new node 153 | // and the user did not provide an auth key. 154 | if st, err := tsNode.Up(ctx); err != nil { 155 | ctrl.Fatalf("Starting tailscale: %v", err) 156 | } else { 157 | log.Printf("Tailscale started, node state %q", st.BackendState) 158 | } 159 | 160 | // Reaching here, we have a running Tailscale node, now we can set up the 161 | // HTTP and/or HTTPS plumbing for TailSQL itself. 162 | tsql, err := tailsql.NewServer(opts) 163 | if err != nil { 164 | ctrl.Fatalf("Creating tailsql server: %v", err) 165 | } 166 | 167 | lst, err := tsNode.Listen("tcp", ":80") 168 | if err != nil { 169 | ctrl.Fatalf("Listen port 80: %v", err) 170 | } 171 | 172 | if opts.ServeHTTPS { 173 | // When serving TLS, add a redirect from HTTP on port 80 to HTTPS on 443. 174 | certDomains := tsNode.CertDomains() 175 | if len(certDomains) == 0 { 176 | ctrl.Fatalf("No cert domains available for HTTPS") 177 | } 178 | base := "https://" + certDomains[0] 179 | go http.Serve(lst, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 180 | target := base + r.RequestURI 181 | http.Redirect(w, r, target, http.StatusPermanentRedirect) 182 | })) 183 | log.Printf("Redirecting HTTP to HTTPS at %q", base) 184 | 185 | // For the real service, start a separate listener. 186 | // Note: Replaces the port 80 listener. 187 | var err error 188 | lst, err = tsNode.ListenTLS("tcp", ":443") 189 | if err != nil { 190 | ctrl.Fatalf("Listen TLS: %v", err) 191 | } 192 | log.Print("Enabled serving via HTTPS") 193 | } 194 | 195 | mux := tsql.NewMux() 196 | tsweb.Debugger(mux) 197 | go http.Serve(lst, mux) 198 | log.Printf("TailSQL started") 199 | <-ctx.Done() 200 | log.Print("TailSQL shutting down...") 201 | return tsNode.Close() 202 | } 203 | 204 | //go:embed sample.tmpl 205 | var sampleConfig string 206 | 207 | func generateBasicConfig(path string) error { 208 | t, err := template.New("sample").Delims("@{", "}@").Parse(sampleConfig) 209 | if err != nil { 210 | ctrl.Fatalf("Parse config template: %v", err) 211 | } 212 | var buf bytes.Buffer 213 | if err := t.Execute(&buf, tailsql.Options{ 214 | Hostname: "tailsql-dev", 215 | LocalState: "tailsql-state.db", 216 | LocalSource: "self", 217 | Sources: []tailsql.DBSpec{{ 218 | Source: "main", 219 | Label: "Test database", 220 | Driver: "sqlite", 221 | URL: "file:test.db?mode=ro", 222 | Named: map[string]string{ 223 | "schema": `select * from sqlite_schema`, 224 | }, 225 | }}, 226 | UILinks: []tailsql.UILink{{ 227 | Anchor: "source code", 228 | URL: "https://github.com/tailscale/tailsql", 229 | }}, 230 | }); err != nil { 231 | ctrl.Fatalf("Generate sample config: %v", err) 232 | } 233 | 234 | // Verify that the generated sample is valid, in case the template got broken. 235 | //lint:ignore S1030 We clone the data because HuJSON clobbers its input slice. 236 | if err := tailsql.UnmarshalOptions([]byte(buf.String()), new(tailsql.Options)); err != nil { 237 | ctrl.Fatalf("Verifying sample config: %v", err) 238 | } 239 | if err := atomicfile.WriteFile(path, buf.Bytes(), 0644); err != nil { 240 | ctrl.Fatalf("Writing sample config: %v", err) 241 | } 242 | log.Printf("Generated sample config in %s", path) 243 | return nil 244 | } 245 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tailscale/tailsql 2 | 3 | go 1.24.4 4 | 5 | require ( 6 | github.com/creachadair/ctrl v0.1.1 7 | github.com/google/go-cmp v0.7.0 8 | github.com/klauspost/compress v1.18.0 9 | github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a 10 | github.com/tailscale/setec v0.0.0-20250611230422-f66888ab66d4 11 | github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694 12 | honnef.co/go/tools v0.6.1 13 | modernc.org/sqlite v1.38.0 14 | tailscale.com v1.87.0-pre.0.20250728175739-4df02bbb486d 15 | ) 16 | 17 | require ( 18 | filippo.io/edwards25519 v1.1.0 // indirect 19 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect 20 | github.com/akutz/memconn v0.1.0 // indirect 21 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 22 | github.com/aws/aws-sdk-go-v2 v1.36.0 // indirect 23 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 // indirect 24 | github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect 25 | github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect 26 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect 27 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 // indirect 28 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 // indirect 29 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect 30 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.31 // indirect 31 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect 32 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.5 // indirect 33 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 // indirect 34 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.12 // indirect 35 | github.com/aws/aws-sdk-go-v2/service/s3 v1.75.3 // indirect 36 | github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0 // indirect 37 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect 38 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect 39 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 // indirect 40 | github.com/aws/smithy-go v1.22.2 // indirect 41 | github.com/coder/websocket v1.8.12 // indirect 42 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect 43 | github.com/creachadair/mds v0.24.1 // indirect 44 | github.com/dblohm7/wingoes v0.0.0-20240123200102-b75a8a7d7eb0 // indirect 45 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect 46 | github.com/dustin/go-humanize v1.0.1 // indirect 47 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 48 | github.com/gaissmai/bart v0.18.0 // indirect 49 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect 50 | github.com/go-ole/go-ole v1.3.0 // indirect 51 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect 52 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 53 | github.com/google/btree v1.1.2 // indirect 54 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect 55 | github.com/google/uuid v1.6.0 // indirect 56 | github.com/hdevalence/ed25519consensus v0.2.0 // indirect 57 | github.com/illarion/gonotify/v3 v3.0.2 // indirect 58 | github.com/insomniacslk/dhcp v0.0.0-20240129002554-15c9b8791914 // indirect 59 | github.com/jmespath/go-jmespath v0.4.0 // indirect 60 | github.com/jsimonetti/rtnetlink v1.4.1 // indirect 61 | github.com/mattn/go-isatty v0.0.20 // indirect 62 | github.com/mdlayher/genetlink v1.3.2 // indirect 63 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect 64 | github.com/mdlayher/sdnotify v1.0.0 // indirect 65 | github.com/mdlayher/socket v0.5.0 // indirect 66 | github.com/miekg/dns v1.1.58 // indirect 67 | github.com/mitchellh/go-ps v1.0.0 // indirect 68 | github.com/ncruces/go-strftime v0.1.9 // indirect 69 | github.com/prometheus-community/pro-bing v0.4.0 // indirect 70 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 71 | github.com/safchain/ethtool v0.3.0 // indirect 72 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect 73 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect 74 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect 75 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect 76 | github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect 77 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect 78 | github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect 79 | github.com/tink-crypto/tink-go/v2 v2.1.0 // indirect 80 | github.com/vishvananda/netns v0.0.4 // indirect 81 | github.com/x448/float16 v0.8.4 // indirect 82 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect 83 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 84 | golang.org/x/crypto v0.38.0 // indirect 85 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 86 | golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect 87 | golang.org/x/mod v0.24.0 // indirect 88 | golang.org/x/net v0.40.0 // indirect 89 | golang.org/x/sync v0.14.0 // indirect 90 | golang.org/x/sys v0.33.0 // indirect 91 | golang.org/x/term v0.32.0 // indirect 92 | golang.org/x/text v0.25.0 // indirect 93 | golang.org/x/time v0.11.0 // indirect 94 | golang.org/x/tools v0.33.0 // indirect 95 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect 96 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 97 | google.golang.org/protobuf v1.35.1 // indirect 98 | gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect 99 | modernc.org/libc v1.65.10 // indirect 100 | modernc.org/mathutil v1.7.1 // indirect 101 | modernc.org/memory v1.11.0 // indirect 102 | ) 103 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | 9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= 2 | 9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= 3 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 4 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 5 | filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= 6 | filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= 7 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= 8 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 9 | github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= 10 | github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= 11 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= 12 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 13 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 14 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 15 | github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk= 16 | github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM= 17 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 h1:zAxi9p3wsZMIaVCdoiQp2uZ9k1LsZvmAnoTBeZPXom0= 18 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8/go.mod h1:3XkePX5dSaxveLAYY7nsbsZZrKxCyEuE5pM4ziFxyGg= 19 | github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= 20 | github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg= 21 | github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y= 22 | github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A= 23 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8= 24 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY= 25 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 h1:lWm9ucLSRFiI4dQQafLrEOmEDGry3Swrz0BIRdiHJqQ= 26 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31/go.mod h1:Huu6GG0YTfbPphQkDSo4dEGmQRTKb9k9G7RdtyQWxuI= 27 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 h1:ACxDklUKKXb48+eg5ROZXi1vDgfMyfIA/WyvqHcHI0o= 28 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31/go.mod h1:yadnfsDwqXeVaohbGc/RaD287PuyRw2wugkh5ZL2J6k= 29 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= 30 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= 31 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.31 h1:8IwBjuLdqIO1dGB+dZ9zJEl8wzY3bVYxcs0Xyu/Lsc0= 32 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.31/go.mod h1:8tMBcuVjL4kP/ECEIWTCWtwV2kj6+ouEKl4cqR4iWLw= 33 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA= 34 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY= 35 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.5 h1:siiQ+jummya9OLPDEyHVb2dLW4aOMe22FGDd0sAfuSw= 36 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.5/go.mod h1:iHVx2J9pWzITdP5MJY6qWfG34TfD9EA+Qi3eV6qQCXw= 37 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 h1:O+8vD2rGjfihBewr5bT+QUfYUHIxCVgG61LHoT59shM= 38 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12/go.mod h1:usVdWJaosa66NMvmCrr08NcWDBRv4E6+YFG2pUdw1Lk= 39 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.12 h1:tkVNm99nkJnFo1H9IIQb5QkCiPcvCDn3Pos+IeTbGRA= 40 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.12/go.mod h1:dIVlquSPUMqEJtx2/W17SM2SuESRaVEhEV9alcMqxjw= 41 | github.com/aws/aws-sdk-go-v2/service/s3 v1.75.3 h1:JBod0SnNqcWQ0+uAyzeRFG1zCHotW8DukumYYyNy0zo= 42 | github.com/aws/aws-sdk-go-v2/service/s3 v1.75.3/go.mod h1:FHSHmyEUkzRbaFFqqm6bkLAOQHgqhsLmfCahvCBMiyA= 43 | github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0 h1:IOdss+igJDFdic9w3WKwxGCmHqUxydvIhJOm9LJ32Dk= 44 | github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= 45 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= 46 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= 47 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= 48 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U= 49 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 h1:3LXNnmtH3TURctC23hnC0p/39Q5gre3FI7BNOiDcVWc= 50 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.13/go.mod h1:7Yn+p66q/jt38qMoVfNvjbm3D89mGBnkwDcijgtih8w= 51 | github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= 52 | github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 53 | github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= 54 | github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= 55 | github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= 56 | github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 57 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= 58 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= 59 | github.com/creachadair/ctrl v0.1.1 h1:sztvK/haLnCSL1IY4mFxEgg5ksAvQMRUFcjB0EeuGY0= 60 | github.com/creachadair/ctrl v0.1.1/go.mod h1:QORxQsErPqDEuQxtRDa907Chq+IwJUkMAhkHFsphops= 61 | github.com/creachadair/mds v0.24.1 h1:bzL4ItCtAUxxO9KkotP0PVzlw4tnJicAcjPu82v2mGs= 62 | github.com/creachadair/mds v0.24.1/go.mod h1:ArfS0vPHoLV/SzuIzoqTEZfoYmac7n9Cj8XPANHocvw= 63 | github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= 64 | github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= 65 | github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= 66 | github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 67 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 68 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 69 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 70 | github.com/dblohm7/wingoes v0.0.0-20240123200102-b75a8a7d7eb0 h1:vrC07UZcgPzu/OjWsmQKMGg3LoPSz9jh/pQXIrHjUj4= 71 | github.com/dblohm7/wingoes v0.0.0-20240123200102-b75a8a7d7eb0/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= 72 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= 73 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= 74 | github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= 75 | github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= 76 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 77 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 78 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 79 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 80 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 81 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 82 | github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= 83 | github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= 84 | github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= 85 | github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= 86 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY= 87 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 88 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 89 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 90 | github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= 91 | github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= 92 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= 93 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= 94 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 95 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 96 | github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= 97 | github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 98 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 99 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 100 | github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= 101 | github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= 102 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= 103 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= 104 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 105 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 106 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 107 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 108 | github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= 109 | github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= 110 | github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= 111 | github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= 112 | github.com/insomniacslk/dhcp v0.0.0-20240129002554-15c9b8791914 h1:kD8PseueGeYiid/Mmcv17Q0Qqicc4F46jcX22L/e/Hs= 113 | github.com/insomniacslk/dhcp v0.0.0-20240129002554-15c9b8791914/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= 114 | github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= 115 | github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= 116 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 117 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 118 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 119 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 120 | github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE= 121 | github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w= 122 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 123 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 124 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= 125 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= 126 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 127 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 128 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 129 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 130 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 131 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 132 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 133 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 134 | github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= 135 | github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= 136 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= 137 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= 138 | github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= 139 | github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= 140 | github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= 141 | github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= 142 | github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= 143 | github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= 144 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 145 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 146 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 147 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 148 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 149 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 150 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 151 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 152 | github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= 153 | github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 154 | github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= 155 | github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= 156 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 157 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 158 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 159 | github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= 160 | github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= 161 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 162 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 163 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 164 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 165 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 166 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 167 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 168 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 169 | github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= 170 | github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= 171 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 172 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= 173 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= 174 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= 175 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= 176 | github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM= 177 | github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= 178 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= 179 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= 180 | github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I= 181 | github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= 182 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= 183 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= 184 | github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= 185 | github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= 186 | github.com/tailscale/setec v0.0.0-20250611230422-f66888ab66d4 h1:JEZbNTVg8RTW7rpd0UAT9Vyum5MwMDUOLpCEYPbKfrs= 187 | github.com/tailscale/setec v0.0.0-20250611230422-f66888ab66d4/go.mod h1:9Aqotyti+m+Z9dlnHOq7Mz+Ap+8SJ2LGGo4kAVyAOco= 188 | github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694 h1:95eIP97c88cqAFU/8nURjgI9xxPbD+Ci6mY/a79BI/w= 189 | github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694/go.mod h1:veguaG8tVg1H/JG5RfpoUW41I+O8ClPElo/fTYr8mMk= 190 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= 191 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= 192 | github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= 193 | github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= 194 | github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= 195 | github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= 196 | github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= 197 | github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= 198 | github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= 199 | github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= 200 | github.com/tink-crypto/tink-go/v2 v2.1.0 h1:QXFBguwMwTIaU17EgZpEJWsUSc60b1BAGTzBIoMdmok= 201 | github.com/tink-crypto/tink-go/v2 v2.1.0/go.mod h1:y1TnYFt1i2eZVfx4OGc+C+EMp4CoKWAw2VSEuoicHHI= 202 | github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= 203 | github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= 204 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= 205 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= 206 | github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= 207 | github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= 208 | github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 209 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 210 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 211 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= 212 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 213 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= 214 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 215 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 216 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 217 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 218 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 219 | golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= 220 | golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 221 | golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= 222 | golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= 223 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 224 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 225 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 226 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 227 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 228 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 229 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 230 | golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 231 | golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 232 | golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 233 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 234 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 235 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 236 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 237 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 238 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 239 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 240 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 241 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 242 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 243 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 244 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 245 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= 246 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= 247 | golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= 248 | golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= 249 | google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= 250 | google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 251 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 252 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 253 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 254 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 255 | gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k= 256 | gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM= 257 | honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= 258 | honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= 259 | howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= 260 | howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 261 | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= 262 | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 263 | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= 264 | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= 265 | modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA= 266 | modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 267 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 268 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 269 | modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc= 270 | modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po= 271 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 272 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 273 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 274 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 275 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 276 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 277 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 278 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 279 | modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= 280 | modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE= 281 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 282 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 283 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 284 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 285 | software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= 286 | software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= 287 | tailscale.com v1.87.0-pre.0.20250728175739-4df02bbb486d h1:8L70NCWpkv1ze68S8oSgqGC/c1der0LGZXPHN3Z5EqE= 288 | tailscale.com v1.87.0-pre.0.20250728175739-4df02bbb486d/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M= 289 | -------------------------------------------------------------------------------- /server/tailsql/files/munk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailsql/7e68a622db06d467214ca06613e9ecd85f8c18ef/server/tailsql/files/munk.png -------------------------------------------------------------------------------- /server/tailsql/internal_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package tailsql 5 | 6 | import ( 7 | "context" 8 | "database/sql" 9 | "os" 10 | "testing" 11 | 12 | "github.com/google/go-cmp/cmp" 13 | "github.com/tailscale/tailsql/authorizer" 14 | "tailscale.com/client/tailscale/apitype" 15 | "tailscale.com/tailcfg" 16 | ) 17 | 18 | // Interface satisfaction checks. 19 | var ( 20 | _ Queryable = sqlDB{} 21 | _ RowSet = (*sql.Rows)(nil) 22 | ) 23 | 24 | func TestCheckQuerySyntax(t *testing.T) { 25 | tests := []struct { 26 | query string 27 | ok bool 28 | }{ 29 | {"", true}, 30 | {" ", true}, 31 | 32 | // Basic disallowed stuff. 33 | {`ATTACH DATABASE "foo" AS bar;`, false}, 34 | {`DETACH DATABASE bar;`, false}, 35 | {`VACUUM`, false}, 36 | {`VACUUM INTO '/dev/null';`, false}, 37 | 38 | // Things that should not be disallowed despite looking sus. 39 | {`SELECT 'ATTACH DATABASE "foo" AS bar;' FROM hell;`, true}, 40 | {`-- attach database not really 41 | select * from a join b using (uid); -- ok `, true}, 42 | {`-- vacuum into 'hell'`, true}, 43 | 44 | // Things that should be disallowed despite being sneaky. 45 | {` -- hide me 46 | attach -- blah blah 47 | database "bad" -- you can't see me 48 | as demon_spawn;`, false}, 49 | } 50 | for _, tc := range tests { 51 | err := checkQuerySyntax(tc.query) 52 | if tc.ok && err != nil { 53 | t.Errorf("Query %q: unexpected error: %v", tc.query, err) 54 | } else if !tc.ok && err == nil { 55 | t.Errorf("Query %q: unexpectedly passed", tc.query) 56 | } 57 | } 58 | } 59 | 60 | func TestOptions(t *testing.T) { 61 | data, err := os.ReadFile("testdata/config.hujson") 62 | if err != nil { 63 | t.Fatalf("Read config: %v", err) 64 | } 65 | var opts Options 66 | if err := UnmarshalOptions(data, &opts); err != nil { 67 | t.Fatalf("Parse config: %v", err) 68 | } 69 | want := Options{ 70 | Hostname: "tailsql-test", 71 | Sources: []DBSpec{{ 72 | Source: "test1", 73 | Label: "Test DB 1", 74 | Driver: "sqlite", 75 | URL: "file::memory:", 76 | }, { 77 | Source: "test2", 78 | Label: "Test DB 2", 79 | Driver: "sqlite", 80 | KeyFile: "testdata/fake-test.key", 81 | }}, 82 | UILinks: []UILink{ 83 | {Anchor: "foo", URL: "http://foo"}, 84 | {Anchor: "bar", URL: "http://bar"}, 85 | }, 86 | } 87 | if diff := cmp.Diff(want, opts); diff != "" { 88 | t.Errorf("Parsed options (-want, +got)\n%s", diff) 89 | } 90 | opts.Authorize = authorizer.ACLGrants(nil) 91 | 92 | // Test that we can populate options from the config. 93 | t.Run("Options", func(t *testing.T) { 94 | dbs, err := opts.openSources(context.Background(), nil) 95 | if err != nil { 96 | t.Fatalf("Options: unexpected error: %v", err) 97 | } 98 | 99 | // The handles should be equinumerous and in the same order as the config. 100 | for i, u := range dbs { 101 | h := u.Get() 102 | if got, want := h.Source(), opts.Sources[i].Source; got != want { 103 | t.Errorf("Database %d: got src %q, want %q", i+1, got, want) 104 | } 105 | h.close() 106 | } 107 | }) 108 | 109 | // Test that the authorizer works. 110 | t.Run("Authorize", func(t *testing.T) { 111 | const tailsqlCap = "tailscale.com/cap/tailsql" 112 | 113 | admin := &apitype.WhoIsResponse{ 114 | Node: new(tailcfg.Node), // must be non-nil in a valid response 115 | UserProfile: &tailcfg.UserProfile{ 116 | ID: 12345, 117 | LoginName: "admin@example.com", 118 | DisplayName: "A. D. Ministratrix", 119 | }, 120 | CapMap: tailcfg.PeerCapMap{ 121 | tailsqlCap: []tailcfg.RawMessage{ 122 | `{"src":["test1","test2"]}`, 123 | }, 124 | }, 125 | } 126 | hoiPolloi := &apitype.WhoIsResponse{ 127 | Node: new(tailcfg.Node), // must be non-nil in a valid response 128 | UserProfile: &tailcfg.UserProfile{ 129 | ID: 67890, 130 | LoginName: "pian@example.com", 131 | DisplayName: "P. Ian McWhorker", 132 | }, 133 | CapMap: tailcfg.PeerCapMap{ 134 | tailsqlCap: []tailcfg.RawMessage{ 135 | `{"src":["test2"]}`, 136 | }, 137 | }, 138 | } 139 | auth := opts.authorize() 140 | 141 | // test1 has access only to admin. 142 | if err := auth("test1", admin); err != nil { 143 | t.Errorf("Authorize admin for test1 failed: %v", err) 144 | } 145 | if err := auth("test1", hoiPolloi); err == nil { 146 | t.Error("Authorize other for test1 should not have succeeded") 147 | } 148 | 149 | // test2 has acces to any user. 150 | if err := auth("test2", admin); err != nil { 151 | t.Errorf("Authorize admin for test2 failed: %v", err) 152 | } 153 | if err := auth("test2", hoiPolloi); err != nil { 154 | t.Errorf("Authorize other for test2 failed: %v", err) 155 | } 156 | }) 157 | } 158 | -------------------------------------------------------------------------------- /server/tailsql/local.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package tailsql 5 | 6 | import ( 7 | "context" 8 | "database/sql" 9 | "errors" 10 | "fmt" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "github.com/tailscale/squibble" 16 | 17 | _ "embed" 18 | ) 19 | 20 | //go:embed state-schema.sql 21 | var localStateSchema string 22 | 23 | // schema is the schema migrator for the local state database. 24 | var schema = &squibble.Schema{ 25 | Current: localStateSchema, 26 | 27 | Updates: []squibble.UpdateRule{ 28 | { 29 | Source: "afc381e1ddcdf41af700bcf24d8d40d99722827d766c1cd65c8799ea51d3e600", 30 | Target: "bc780f7ed5ce806cd9c413e657c29c0a2b6770b1a2c28ba4ecdd5724a5fbfbdd", 31 | Apply: squibble.Exec( 32 | `ALTER TABLE raw_query_log ADD COLUMN elapsed INTEGER NULL`, 33 | `DROP VIEW query_log`, 34 | `CREATE VIEW query_log AS SELECT author, source, query, timestamp, elapsed `+ 35 | `FROM raw_query_log JOIN queries USING (query_id)`, 36 | ), 37 | }, 38 | }, 39 | } 40 | 41 | // localState represetns a local database used by the service to track optional 42 | // state information while running. 43 | type localState struct { 44 | // Exclusive: Write transaction 45 | // Shared: Read transaction 46 | txmu sync.RWMutex 47 | rw, ro *sql.DB 48 | } 49 | 50 | // newLocalState constructs a new LocalState helper for the given database URL. 51 | func newLocalState(url string) (*localState, error) { 52 | if !strings.HasPrefix(url, "file:") { 53 | url = "file:" + url 54 | } 55 | urlRO := url + "?mode=ro" 56 | 57 | // Open separate copies of the database for writing query logs vs. serving 58 | // queries to the UI. 59 | rw, err := openAndPing("sqlite", url) 60 | if err != nil { 61 | return nil, err 62 | } 63 | if err := schema.Apply(context.Background(), rw); err != nil { 64 | rw.Close() 65 | return nil, fmt.Errorf("initializing schema: %w", err) 66 | } 67 | 68 | ro, err := openAndPing("sqlite", urlRO) 69 | if err != nil { 70 | rw.Close() 71 | return nil, err 72 | } 73 | 74 | return &localState{rw: rw, ro: ro}, nil 75 | } 76 | 77 | // LogQuery adds the specified query to the query log. 78 | // The user is the login of the user originating the query, q is the source 79 | // database and query SQL text. 80 | // If elapsed > 0, it is recorded as the elapsed execution time. 81 | // 82 | // If s == nil, the query is discarded without error. 83 | func (s *localState) LogQuery(ctx context.Context, user string, q Query, elapsed time.Duration) error { 84 | if s == nil { 85 | return nil // OK, nothing to do 86 | } 87 | s.txmu.Lock() 88 | defer s.txmu.Unlock() 89 | tx, err := s.rw.BeginTx(ctx, nil) 90 | if err != nil { 91 | return err 92 | } 93 | defer tx.Rollback() 94 | 95 | // Look up or insert the query into the queries table to get an ID. 96 | var queryID int64 97 | err = tx.QueryRow(`SELECT query_id FROM queries WHERE query = ?`, q.Query).Scan(&queryID) 98 | if errors.Is(err, sql.ErrNoRows) { 99 | err = tx.QueryRow(`INSERT INTO QUERIES (query) VALUES (?) RETURNING (query_id)`, q.Query).Scan(&queryID) 100 | } 101 | if err != nil { 102 | return fmt.Errorf("update query ID: %w", err) 103 | } 104 | 105 | // Add a log entry referencing the query ID. 106 | ecol := sql.NullInt64{Int64: int64(elapsed / time.Microsecond), Valid: elapsed > 0} 107 | _, err = tx.Exec(`INSERT INTO raw_query_log (author, source, query_id, elapsed) VALUES (?, ?, ?, ?)`, 108 | user, q.Source, queryID, ecol) 109 | if err != nil { 110 | return fmt.Errorf("update query log: %w", err) 111 | } 112 | 113 | return tx.Commit() 114 | } 115 | 116 | // Query satisfies part of the Queryable interface. It supports only read queries. 117 | func (s *localState) Query(ctx context.Context, query string, params ...any) (RowSet, error) { 118 | s.txmu.RLock() 119 | defer s.txmu.RUnlock() 120 | return s.ro.QueryContext(ctx, query, params...) 121 | } 122 | 123 | // Close satisfies part of the Queryable interface. For this database the 124 | // implementation is a no-op without error. 125 | func (*localState) Close() error { return nil } 126 | -------------------------------------------------------------------------------- /server/tailsql/metrics.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package tailsql 5 | 6 | import ( 7 | "expvar" 8 | 9 | "tailscale.com/metrics" 10 | ) 11 | 12 | var ( 13 | apiRequestCount = &metrics.LabelMap{Label: "type"} 14 | htmlRequestCount = new(expvar.Int) 15 | csvRequestCount = new(expvar.Int) 16 | jsonRequestCount = new(expvar.Int) 17 | metaRequestCount = new(expvar.Int) 18 | apiErrorCount = &metrics.LabelMap{Label: "type"} 19 | authErrorCount = new(expvar.Int) 20 | badRequestErrorCount = new(expvar.Int) 21 | internalErrorCount = new(expvar.Int) 22 | queryErrorCount = new(expvar.Int) 23 | ) 24 | 25 | func init() { 26 | apiRequestCount.Set("html", htmlRequestCount) 27 | apiRequestCount.Set("csv", csvRequestCount) 28 | apiRequestCount.Set("json", jsonRequestCount) 29 | apiRequestCount.Set("meta", metaRequestCount) 30 | 31 | apiErrorCount.Set("auth", authErrorCount) 32 | apiErrorCount.Set("bad_request", badRequestErrorCount) 33 | apiErrorCount.Set("internal", internalErrorCount) 34 | apiErrorCount.Set("query", queryErrorCount) 35 | } 36 | 37 | func addMetrics(m *expvar.Map) { 38 | m.Set("counter_api_request", apiRequestCount) 39 | m.Set("counter_api_error", apiErrorCount) 40 | } 41 | -------------------------------------------------------------------------------- /server/tailsql/options.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package tailsql 5 | 6 | import ( 7 | "context" 8 | "database/sql" 9 | "encoding/json" 10 | "errors" 11 | "expvar" 12 | "fmt" 13 | "log" 14 | "os" 15 | "regexp" 16 | "strings" 17 | "sync" 18 | "sync/atomic" 19 | "time" 20 | 21 | "github.com/tailscale/hujson" 22 | "github.com/tailscale/setec/client/setec" 23 | "tailscale.com/client/tailscale/apitype" 24 | "tailscale.com/types/logger" 25 | ) 26 | 27 | // Options describes settings for a Server. 28 | // 29 | // The fields marked as "tsnet" are not used directly by tailsql, but are 30 | // provided for the convenience of a main program that wants to run the server 31 | // under tsnet. 32 | type Options struct { 33 | // The tailnet hostname the server should run on (tsnet). 34 | Hostname string `json:"hostname,omitempty"` 35 | 36 | // The directory for tailscale state and configurations (tsnet). 37 | StateDir string `json:"stateDir,omitempty"` 38 | 39 | // If true, serve HTTPS instead of HTTP (tsnet). 40 | ServeHTTPS bool `json:"serveHTTPS,omitempty"` 41 | 42 | // If non-empty, a SQLite database URL to use for local state. 43 | LocalState string `json:"localState,omitempty"` 44 | 45 | // If non-empty, and LocalState is defined, export a read-only copy of the 46 | // local state database as a source with this name. 47 | LocalSource string `json:"localSource,omitempty"` 48 | 49 | // Databases that the server will allow queries against (optional). 50 | Sources []DBSpec `json:"sources,omitempty"` 51 | 52 | // Additional links that should be propagated to the UI. 53 | UILinks []UILink `json:"links,omitempty"` 54 | 55 | // If set, prepend this prefix to each HTTP route. By default, routes are 56 | // anchored at "/". 57 | RoutePrefix string `json:"routePrefix,omitempty"` 58 | 59 | // The maximum timeout for a database query (0 means no timeout). 60 | QueryTimeout Duration `json:"queryTimeout,omitempty"` 61 | 62 | // The fields below are not encoded for storage. 63 | 64 | // A connection to tailscaled for authorization checks. If nil, no 65 | // authorization checks are performed and all requests are permitted. 66 | LocalClient LocalClient `json:"-"` 67 | 68 | // If non-nil, the server will add metrics to this map. The caller is 69 | // responsible for ensuring the map is published. 70 | Metrics *expvar.Map `json:"-"` 71 | 72 | // If non-nil and a LocalClient is available, Authorize is called for each 73 | // request giving the requested database src and the caller's WhoIs record. 74 | // If it reports an error, the request is failed. 75 | // 76 | // If Authorize is nil and a LocalClient is available, the default rule is 77 | // to accept any logged-in user, rejecting tagged nodes. 78 | // 79 | // If no LocalClient is available, this field is ignored, no authorization 80 | // checks are performed, and all requests are accepted. 81 | Authorize func(src string, info *apitype.WhoIsResponse) error `json:"-"` 82 | 83 | // If non-nil, use this store to fetch secret values. This is required if 84 | // any of the sources specifies a named secret for its connection string. 85 | SecretStore *setec.Store `json:"-"` 86 | 87 | // Optional rules to apply when rendering text for presentation in the UI. 88 | // After generating the value string, each rule is matched in order, and the 89 | // first match (if any) is applied to rewrite the output. The value returned 90 | // by the rule replaces the original string. 91 | UIRewriteRules []UIRewriteRule `json:"-"` 92 | 93 | // If non-nil, call this function with each query presented to the API. If 94 | // the function reports an error, the query fails; otherwise the returned 95 | // query state is used to service the query. If nil, DefaultCheckQuery is 96 | // used. 97 | CheckQuery func(Query) (Query, error) `json:"-"` 98 | 99 | // If non-nil, send logs to this logger. If nil, use log.Printf. 100 | Logf logger.Logf `json:"-"` 101 | } 102 | 103 | // checkQuery returns the query check function specified by options, or a 104 | // default that accepts all queries as given. 105 | func (o Options) checkQuery() func(Query) (Query, error) { 106 | if o.CheckQuery == nil { 107 | return DefaultCheckQuery 108 | } 109 | return o.CheckQuery 110 | } 111 | 112 | // openSources opens database handles to each of the sources defined by o. 113 | // Sources that require secrets will get them from store. 114 | // Precondition: All the sources of o have already been validated. 115 | func (o Options) openSources(ctx context.Context, store *setec.Store) ([]*setec.Updater[*dbHandle], error) { 116 | if len(o.Sources) == 0 { 117 | return nil, nil 118 | } 119 | 120 | srcs := make([]*setec.Updater[*dbHandle], len(o.Sources)) 121 | for i, spec := range o.Sources { 122 | if spec.Label == "" { 123 | spec.Label = "(unidentified database)" 124 | } 125 | 126 | // Case 1: A programmatic source. 127 | if spec.DB != nil { 128 | srcs[i] = setec.StaticUpdater(&dbHandle{ 129 | src: spec.Source, 130 | label: spec.Label, 131 | named: spec.Named, 132 | db: spec.DB, 133 | }) 134 | continue 135 | } 136 | 137 | // Case 2: A database managed by database/sql, with a secret from setec. 138 | if spec.Secret != "" { 139 | // We actually only maintain a single value, that is updated in-place. 140 | h := &dbHandle{src: spec.Source, label: spec.Label, named: spec.Named} 141 | u, err := setec.NewUpdater(ctx, store, spec.Secret, func(secret []byte) (*dbHandle, error) { 142 | db, err := openAndPing(spec.Driver, string(secret)) 143 | if err != nil { 144 | return nil, err 145 | } 146 | o.logf()("[tailsql] opened new connection for source %q", spec.Source) 147 | h.mu.Lock() 148 | defer h.mu.Unlock() 149 | if h.db != nil { 150 | h.db.Close() // close the active handle 151 | } 152 | if up := h.checkUpdate(); up != nil { 153 | up.newDB.Close() // close a previous pending update 154 | } 155 | h.db = sqlDB{DB: db} 156 | return h, nil 157 | }) 158 | if err != nil { 159 | return nil, err 160 | } 161 | srcs[i] = u 162 | continue 163 | } 164 | 165 | // Case 3: A database managed by database/sql, with a fixed URL. 166 | var connString string 167 | switch { 168 | case spec.URL != "": 169 | connString = spec.URL 170 | case spec.KeyFile != "": 171 | data, err := os.ReadFile(os.ExpandEnv(spec.KeyFile)) 172 | if err != nil { 173 | return nil, fmt.Errorf("read key file for %q: %w", spec.Source, err) 174 | } 175 | connString = strings.TrimSpace(string(data)) 176 | default: 177 | panic("unexpected: no connection source is defined after validation") 178 | } 179 | 180 | // Open and ping the database to ensure it is approximately usable. 181 | db, err := openAndPing(spec.Driver, connString) 182 | if err != nil { 183 | return nil, err 184 | } 185 | srcs[i] = setec.StaticUpdater(&dbHandle{ 186 | src: spec.Source, 187 | driver: spec.Driver, 188 | label: spec.Label, 189 | named: spec.Named, 190 | db: sqlDB{DB: db}, 191 | }) 192 | } 193 | return srcs, nil 194 | } 195 | 196 | func openAndPing(driver, connString string) (*sql.DB, error) { 197 | db, err := sql.Open(driver, connString) 198 | if err != nil { 199 | return nil, fmt.Errorf("open %s: %w", driver, err) 200 | } else if err := db.PingContext(context.Background()); err != nil { 201 | db.Close() 202 | return nil, fmt.Errorf("ping %s: %w", driver, err) 203 | } 204 | return db, nil 205 | } 206 | 207 | // CheckSources validates the sources of o. If this succeeds, it also returns a 208 | // slice of any secret names required by the specified sources, if any. 209 | func (o Options) CheckSources() ([]string, error) { 210 | var secrets []string 211 | for i := range o.Sources { 212 | if err := o.Sources[i].checkValid(); err != nil { 213 | return nil, err 214 | } 215 | if s := o.Sources[i].Secret; s != "" { 216 | secrets = append(secrets, s) 217 | } 218 | } 219 | return secrets, nil 220 | } 221 | 222 | func (o Options) localState() (*localState, error) { 223 | if o.LocalState == "" { 224 | return nil, nil 225 | } 226 | url := os.ExpandEnv(o.LocalState) 227 | return newLocalState(url) 228 | } 229 | 230 | func (o Options) routePrefix() string { 231 | if o.RoutePrefix != "" { 232 | // Routes are anchored at "/" by default, so remove a trailing "/" if 233 | // there is one. E.g., "/foo/" beomes "/foo", and "/" becomes "". 234 | return strings.TrimSuffix(o.RoutePrefix, "/") 235 | } 236 | return "" 237 | } 238 | 239 | func (o Options) logf() logger.Logf { 240 | if o.Logf == nil { 241 | return log.Printf 242 | } 243 | return o.Logf 244 | } 245 | 246 | // authorize returns an authorization callback based on the Access field of o. 247 | func (o Options) authorize() func(src string, who *apitype.WhoIsResponse) error { 248 | if o.Authorize != nil { 249 | return o.Authorize 250 | } 251 | 252 | logf := o.logf() 253 | return func(dataSrc string, who *apitype.WhoIsResponse) (err error) { 254 | caller := who.UserProfile.LoginName 255 | if who.Node.IsTagged() { 256 | caller = who.Node.Name 257 | } 258 | defer func() { 259 | logf("[tailsql] auth src=%q who=%q err=%v", dataSrc, caller, err) 260 | }() 261 | if who.Node.IsTagged() { 262 | return errors.New("tagged node is not authorized") 263 | } 264 | return nil 265 | } 266 | } 267 | 268 | // UILink carries anchor text and a target URL for a hyperlink. 269 | type UILink struct { 270 | Anchor string `json:"anchor"` 271 | URL string `json:"url"` 272 | } 273 | 274 | // UIRewriteRule is a rewriting rule for rendering output in HTML. 275 | // 276 | // A rule matches a value if: 277 | // 278 | // - Its Column regexp is empty or matches the column name, and 279 | // - Its Value regexp is empty or matches the value string 280 | // 281 | // If a rule matches, its Apply function is called. 282 | type UIRewriteRule struct { 283 | Column *regexp.Regexp // pattern for the column name (nil matches all) 284 | Value *regexp.Regexp // pattern for the value (nil matches all) 285 | 286 | // The Apply function takes the name of a column, the input value, and the 287 | // result of matching the value regexp (if any). Its return value replaces 288 | // the input when the value is rendered. If Apply == nil, the input is not 289 | // modified. 290 | // 291 | // As a special case, if Apply returns a nil value, the rule evaluator skips 292 | // the rule as if it had not matched, and goes on to the next rule. 293 | Apply func(column, input string, valueMatch []string) any 294 | } 295 | 296 | // CheckApply reports whether u matches the specified column and input, and if 297 | // so returns the result of applying u to it. 298 | func (u UIRewriteRule) CheckApply(column, input string) (bool, any) { 299 | if u.Column != nil && !u.Column.MatchString(column) { 300 | return false, nil // no match for this column name 301 | } 302 | 303 | var m []string 304 | if u.Value != nil { 305 | // If there is a regexp but it doesn't match, fail this rule. 306 | // If there is no regexp we accept all values (with an empty match). 307 | m = u.Value.FindStringSubmatch(input) 308 | if m == nil { 309 | return false, nil 310 | } 311 | } 312 | if u.Apply == nil { 313 | return true, input 314 | } 315 | v := u.Apply(column, input, m) 316 | if v == nil { 317 | return false, nil 318 | } 319 | return true, v 320 | } 321 | 322 | // A DBHandle wraps an open SQL database with descriptive metadata. 323 | // The handle permits a provider, which creates the handle, to share the 324 | // database with a reader, and to safely swap to a new database. 325 | // 326 | // This is used to allow a data source being used by a Server to safely be 327 | // updated with a new underlying database. The Swap method ensures the new 328 | // value is exchanged without races. 329 | type dbHandle struct { 330 | src string 331 | driver string 332 | 333 | // If not nil, the value of this field is a database update that arrived 334 | // while the handle was busy running a query. The concrete type is *dbUpdate 335 | // once initialized. 336 | update atomic.Value 337 | 338 | // mu protects the fields below. 339 | // Hold shared to read the label and issue queries against db. 340 | // Hold exclusive to replace or close db or to update label. 341 | mu sync.RWMutex 342 | label string 343 | db Queryable 344 | named map[string]string 345 | } 346 | 347 | // checkUpdate returns nil if there is no pending update, otherwise it swaps 348 | // out the pending database update and returns it. 349 | func (h *dbHandle) checkUpdate() *dbUpdate { 350 | if up := h.update.Swap((*dbUpdate)(nil)); up != nil { 351 | return up.(*dbUpdate) 352 | } 353 | return nil 354 | } 355 | 356 | // tryUpdate checks whether h is busy with a query. If not, and there is a 357 | // handle update pending, tryUpdate applies it. 358 | func (h *dbHandle) tryUpdate() { 359 | if h.mu.TryLock() { // if not, the handle is busy; try again later 360 | defer h.mu.Unlock() 361 | if up := h.checkUpdate(); up != nil { 362 | h.applyUpdateLocked(up) 363 | } 364 | } 365 | } 366 | 367 | // applyUpdateLocked applies up to h, which must be locked exclusively. 368 | func (h *dbHandle) applyUpdateLocked(up *dbUpdate) { 369 | h.label = up.label 370 | h.named = up.named 371 | h.db.Close() 372 | h.db = up.newDB 373 | } 374 | 375 | // Source returns the source name defined for h. 376 | func (h *dbHandle) Source() string { return h.src } 377 | 378 | // Label returns the label defined for h. 379 | func (h *dbHandle) Label() string { 380 | h.mu.RLock() 381 | defer h.mu.RUnlock() 382 | return h.label 383 | } 384 | 385 | // Named returns the named queries for h, nil if there are none. 386 | func (h *dbHandle) Named() map[string]string { 387 | h.mu.RLock() 388 | defer h.mu.RUnlock() 389 | return h.named 390 | } 391 | 392 | // WithLock calls f with the wrapped database while holding the lock. 393 | // If f reports an error is returned to the caller of WithLock. 394 | // WithLock reports an error without calling f if h is closed. 395 | // The context passed to f can be used to look up named queries on h using 396 | // lookupNamedQuery. 397 | func (h *dbHandle) WithLock(ctx context.Context, f func(context.Context, Queryable) error) error { 398 | h.mu.RLock() 399 | defer h.mu.RUnlock() 400 | if h.db == nil { 401 | return errors.New("handle is closed") 402 | } 403 | 404 | // We hold the lock here not to exclude concurrent connections, which are 405 | // safe, but to prevent the handle from being swapped (and the database 406 | // closed) while connections are in-flight. 407 | // 408 | // Attach the handle to the context during the lifetime of f. This ensures 409 | // that f has access to named queries and other options from h while holding 410 | // the lock on h. 411 | fctx := context.WithValue(ctx, dbHandleKey{}, h) 412 | return f(fctx, h.db) 413 | } 414 | 415 | type dbHandleKey struct{} 416 | 417 | // lookupNamedQuery reports whether the database handle associated with ctx has 418 | // a named query with the given name, and if so returns the text of the query. 419 | // If ctx does not have a database handle, it returns ("", false) always. The 420 | // context passed to the callback of Tx has a handle attached. 421 | func lookupNamedQuery(ctx context.Context, name string) (string, bool) { 422 | if v := ctx.Value(dbHandleKey{}); v != nil { 423 | // Precondition: The handle lock is held. 424 | q, ok := v.(*dbHandle).named[name] 425 | return q, ok 426 | } 427 | return "", false 428 | } 429 | 430 | // swap locks the handle, swaps the current contents of the handle with newDB 431 | // and newLabel, and closes the original value. The caller is responsible for 432 | // closing a database handle when it is no longer in use. It will panic if 433 | // newDB == nil, or if h is closed. 434 | func (h *dbHandle) swap(newDB Queryable, newOpts *DBOptions) { 435 | if newDB == nil { 436 | panic("new database is nil") 437 | } 438 | 439 | up := &dbUpdate{ 440 | newDB: newDB, 441 | label: newOpts.label(), 442 | named: newOpts.namedQueries(), 443 | } 444 | 445 | // If the handle is not busy, do the swap now. 446 | if h.mu.TryLock() { 447 | defer h.mu.Unlock() 448 | if h.db == nil { 449 | panic("handle is closed") 450 | } 451 | h.applyUpdateLocked(up) 452 | return 453 | } 454 | 455 | // Reaching here, the handle is busy on a query. Record an update to be 456 | // plumbed in later. It's possible we already had a pending update -- if 457 | // that happens close out the old one. 458 | if old := h.update.Swap(up); old != nil { 459 | if up := old.(*dbUpdate); up != nil { 460 | up.newDB.Close() 461 | } 462 | } 463 | } 464 | 465 | // A dbUpdate is an open database handle, label, and set of named queries that 466 | // are ready to be installed in a database handle. 467 | type dbUpdate struct { 468 | newDB Queryable 469 | label string 470 | named map[string]string 471 | } 472 | 473 | // close closes the handle, calling Close on the underlying database and 474 | // reporting its result. It is safe to call close multiple times; successive 475 | // calls will report nil. 476 | func (h *dbHandle) close() error { 477 | h.mu.Lock() 478 | defer h.mu.Unlock() 479 | if h.db != nil { 480 | err := h.db.Close() 481 | h.db = nil 482 | return err 483 | } 484 | return nil 485 | } 486 | 487 | // UnmarshalOptions unmarshals a HuJSON Config value into opts. 488 | func UnmarshalOptions(data []byte, opts *Options) error { 489 | data, err := hujson.Standardize(data) 490 | if err != nil { 491 | return err 492 | } 493 | return json.Unmarshal(data, &opts) 494 | } 495 | 496 | // Duration is a wrapper for a time.Duration that allows it to marshal more 497 | // legibly in JSON, using the standard Go notation. 498 | type Duration time.Duration 499 | 500 | // Duration converts d to a standard time.Duration. 501 | func (d Duration) Duration() time.Duration { return time.Duration(d) } 502 | 503 | func (d Duration) MarshalText() ([]byte, error) { 504 | return []byte(time.Duration(d).String()), nil 505 | } 506 | 507 | func (d *Duration) UnmarshalText(data []byte) error { 508 | td, err := time.ParseDuration(string(data)) 509 | if err != nil { 510 | return err 511 | } 512 | *d = Duration(td) 513 | return nil 514 | } 515 | 516 | // A DBSpec describes a database that the server should use. 517 | // 518 | // The Source must be non-empty, and exactly one of URL, KeyFile, Secret, or DB 519 | // must be set. 520 | // 521 | // - If DB is set, it is used directly as the database to query, and no 522 | // connection is established. 523 | // 524 | // Otherwise, the Driver must be non-empty and the [database/sql] library is 525 | // used to open a connection to the specified database: 526 | // 527 | // - If URL is set, it is used directly as the connection string. 528 | // 529 | // - If KeyFile is set, it names the location of a file containing the 530 | // connection string. If set, KeyFile is expanded by os.ExpandEnv. 531 | // 532 | // - Otherwise, Secret is the name of a secret to fetch from the secrets 533 | // service, whose value is the connection string. This requires that a 534 | // secrets server be configured in the options. 535 | type DBSpec struct { 536 | Source string `json:"source"` // UI slug (required) 537 | Label string `json:"label,omitempty"` // descriptive label 538 | Driver string `json:"driver,omitempty"` // e.g., "sqlite", "snowflake" 539 | 540 | // Named is an optional map of named SQL queries the database should expose. 541 | Named map[string]string `json:"named,omitempty"` 542 | 543 | // Exactly one of the fields below must be set. 544 | 545 | URL string `json:"url,omitempty"` // path or connection URL 546 | KeyFile string `json:"keyFile,omitempty"` // path to key file 547 | Secret string `json:"secret,omitempty"` // name of secret 548 | DB Queryable `json:"-"` // programmatic data source 549 | } 550 | 551 | func (d *DBSpec) countFields() (n int) { 552 | for _, s := range []string{d.URL, d.KeyFile, d.Secret} { 553 | if s != "" { 554 | n++ 555 | } 556 | } 557 | return 558 | } 559 | 560 | func (d *DBSpec) checkValid() error { 561 | if d.Source == "" { 562 | return errors.New("missing source name") 563 | } 564 | 565 | // Case 1: A programmatic data source. 566 | if d.DB != nil { 567 | if d.countFields() != 0 { 568 | return errors.New("no connection string is allowed when DB is set") 569 | } 570 | return nil 571 | } 572 | 573 | // Case 2: A database/sql database. 574 | if d.Driver == "" { 575 | return errors.New("missing driver name") 576 | } else if d.countFields() != 1 { 577 | return errors.New("exactly one connection source must be set") 578 | } 579 | return nil 580 | } 581 | 582 | // DBOptions are optional settings for a database. A nil *DBoptions is ready 583 | // for use and provides defaults as described. 584 | type DBOptions struct { 585 | // Label is a human-readable descriptive label to show to users when 586 | // rendering this database in a UI. 587 | Label string 588 | 589 | // NamedQueries is a map from names to SQL query text, that the service 590 | // should allow as pre-defined queries for this database. 591 | // 592 | // Unlike user-saved queries, named queries allow the database to change the 593 | // query when the underlying schema changes while preserving the semantics 594 | // the user observes. 595 | NamedQueries map[string]string 596 | } 597 | 598 | func (o *DBOptions) label() string { 599 | if o == nil { 600 | return "" 601 | } 602 | return o.Label 603 | } 604 | 605 | func (o *DBOptions) namedQueries() map[string]string { 606 | if o == nil { 607 | return nil 608 | } 609 | return o.NamedQueries 610 | } 611 | 612 | // A Query carries the parameters of a query presented to the API. 613 | type Query struct { 614 | Source string // the data source requested 615 | Query string // the text of the query 616 | } 617 | 618 | // DefaultCheckQuery is the default query check function used if another is not 619 | // specified in the Options. It accepts all queries for all sources, as long as 620 | // the query text does not exceed 4000 bytes. 621 | func DefaultCheckQuery(q Query) (Query, error) { 622 | // Reject query strings that are egregiously too long. 623 | const maxQueryBytes = 4000 624 | 625 | if len(q.Query) > maxQueryBytes { 626 | return q, errors.New("query too long") 627 | } 628 | return q, nil 629 | } 630 | 631 | // Queryable is the interface used to issue SQL queries to a database. 632 | type Queryable interface { 633 | // Query issues the specified SQL query in a transaction and returns the 634 | // matching result set, if any. 635 | Query(ctx context.Context, sql string, params ...any) (RowSet, error) 636 | 637 | // Close closes the database. 638 | Close() error 639 | } 640 | 641 | // A RowSet is a sequence of rows reported by a query. It is a subset of the 642 | // interface exposed by [database/sql.Rows], and the implementation must 643 | // provide the same semantics for each of these methods. 644 | type RowSet interface { 645 | // Columns reports the names of the columns requested by the query. 646 | Columns() ([]string, error) 647 | 648 | // Close closes the row set, preventing further enumeration. 649 | Close() error 650 | 651 | // Err returns the error, if any, that was encountered during iteration. 652 | Err() error 653 | 654 | // Next prepares the next result row for reading by the Scan method, and 655 | // reports true if this was successful or false if there was an error or no 656 | // more rows are available. 657 | Next() bool 658 | 659 | // Scan copies the columns of the currently-selected row into the values 660 | // pointed to by its arguments. 661 | Scan(...any) error 662 | } 663 | 664 | type sqlDB struct{ *sql.DB } 665 | 666 | func (s sqlDB) Query(ctx context.Context, query string, params ...any) (RowSet, error) { 667 | return s.DB.QueryContext(ctx, query, params...) 668 | } 669 | -------------------------------------------------------------------------------- /server/tailsql/state-schema.sql: -------------------------------------------------------------------------------- 1 | -- Local state schema. 2 | 3 | -- Unique queries by spelling. 4 | CREATE TABLE IF NOT EXISTS queries ( 5 | query_id INTEGER PRIMARY KEY AUTOINCREMENT, 6 | query TEXT NOT NULL, -- SQL query text 7 | 8 | UNIQUE (query) 9 | ); 10 | 11 | -- A log of all successful queries completed by the service, per user. 12 | CREATE TABLE IF NOT EXISTS raw_query_log ( 13 | author TEXT NULL, -- login of query author, if known 14 | source TEXT NOT NULL, -- source label, e.g., "main", "raw" 15 | 16 | query_id INTEGER NOT NULL 17 | REFERENCES queries (query_id), 18 | timestamp TIMESTAMP NOT NULL 19 | DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')), 20 | elapsed INTEGER NULL -- microseconds 21 | ); 22 | 23 | -- A joined view of the query log. 24 | CREATE VIEW IF NOT EXISTS query_log AS 25 | SELECT author, source, query, timestamp, elapsed 26 | FROM raw_query_log JOIN queries 27 | USING (query_id) 28 | ; 29 | -------------------------------------------------------------------------------- /server/tailsql/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailsql/7e68a622db06d467214ca06613e9ecd85f8c18ef/server/tailsql/static/favicon.ico -------------------------------------------------------------------------------- /server/tailsql/static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /server/tailsql/static/nut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailsql/7e68a622db06d467214ca06613e9ecd85f8c18ef/server/tailsql/static/nut.png -------------------------------------------------------------------------------- /server/tailsql/static/script.js: -------------------------------------------------------------------------------- 1 | import { Params, Area, Cycle, Loop } from './sprite.js'; 2 | 3 | (() => { 4 | const query = document.getElementById('query'); 5 | const qButton = document.getElementById('send-query'); 6 | const dlButton = document.getElementById("dl-button"); 7 | const qform = document.getElementById('qform'); 8 | const output = document.getElementById('output'); 9 | const base = document.location.origin + document.location.pathname; 10 | const sources = document.getElementById('sources'); 11 | const body = document.getElementById('tsql'); 12 | 13 | const nuts = /a?corn|\bnut\b|seed|squirrel|tailsql/i; 14 | const velo = 5, delay = 60, runChance = 0.03; 15 | let hasRun = false; 16 | 17 | const param = new Params(256, 256, 8, 8); 18 | const aRunRight = new Loop(velo, 0, 5, [5,1,2,3]); 19 | const aRunLeft = new Loop(-velo, 0, 6, [5,1,2,3]); 20 | 21 | function hasQuery() { 22 | return query.value.trim() != ""; 23 | } 24 | 25 | function shouldSquirrel() { 26 | return !hasRun && 27 | (query.value.trim().match(nuts) || 28 | new Date().toTimeString().slice(0, 5) == "16:20" 29 | ) && 30 | Math.random() < runChance; 31 | } 32 | 33 | function maybeRunSquirrel() { 34 | if (!shouldSquirrel()) { 35 | return; 36 | } 37 | // Squirrel art from: 38 | // http://saralara93.blogspot.com/2014/03/concept-art-part-3-squirrel.html 39 | 40 | const nut = document.getElementById("nut"); 41 | if (nut === null) { return; } // UI not configured 42 | 43 | const isOdd = query.value.length%2 == 1; 44 | const area = new Area({ 45 | figure: nut, 46 | params: param, 47 | startx: isOdd ? 100 : 0, 48 | wrap: false, 49 | }); 50 | const cycle = new Cycle(isOdd ? aRunLeft : aRunRight); 51 | hasRun = true; 52 | area.setVisible(true); 53 | let timer = setInterval(() => { 54 | if (cycle.update(area)) { 55 | clearInterval(timer); 56 | timer = null; 57 | area.setVisible(false); 58 | } 59 | }, delay); 60 | } 61 | 62 | query.addEventListener("keydown", (evt) => { 63 | if (evt.shiftKey && evt.key == "Enter") { 64 | evt.preventDefault(); 65 | if (hasQuery()) { 66 | qButton.click(); 67 | } 68 | } 69 | maybeRunSquirrel() 70 | }) 71 | 72 | body.addEventListener("keydown", (evt) => { 73 | if (evt.altKey) { 74 | var c = evt.code.match(/^Digit(\d)$/); 75 | if (c) { 76 | var v = parseInt(c[1]); 77 | if (v > 0 && v <= sources.options.length) { 78 | evt.preventDefault(); 79 | sources.options[v-1].selected = true; 80 | } 81 | } 82 | } 83 | }); 84 | 85 | function performDownload(name, url) { 86 | var link = document.createElement('a'); 87 | link.setAttribute('href', url); 88 | link.setAttribute('download', name); 89 | document.body.appendChild(link); 90 | link.click(); 91 | document.body.removeChild(link); 92 | } 93 | 94 | function disableIfNoOutput(elt) { 95 | return (evt) => { 96 | elt.disabled = !output; 97 | } 98 | } 99 | 100 | dlButton.addEventListener("click", (evt) => { 101 | var fd = new FormData(qform); 102 | var sp = new URLSearchParams(fd); 103 | var href = base + "csv?" + sp.toString(); 104 | 105 | performDownload('query.csv', href); 106 | }); 107 | 108 | // Disable the download button when there are no query results. 109 | window.addEventListener("load", disableIfNoOutput(dlButton)); 110 | // Initially focus the query input. 111 | window.addEventListener("load", (evt) => { query.focus(); }); 112 | // Refresh when the input source changes. 113 | sources.addEventListener('change', (evt) => { qform.submit() }); 114 | })() 115 | -------------------------------------------------------------------------------- /server/tailsql/static/sprite.js: -------------------------------------------------------------------------------- 1 | // Module sprite defines a basic sprite animation library. 2 | 3 | // Animation parameters. 4 | export class Params { 5 | // Construct parameters for a sprite sheet of the given dimensions. 6 | constructor(width, height, nrow, ncol) { 7 | this.width = width; 8 | this.height = height; 9 | this.nrow = nrow; 10 | this.ncol = ncol; 11 | this.fh = height / nrow; 12 | this.fw = width / ncol; 13 | } 14 | } 15 | 16 | // An Area defines the region where an animation will play. 17 | // The figure is an element containing the sprite sheet as a background image. 18 | // Animation is constrained to the bounding box of the parent element of the figure. 19 | export class Area { 20 | #img; #params; #hcap; #vcap; #locx; #locy; #wrap; 21 | 22 | // Initialize an area for the given figure and parameters. 23 | // The figure is the element containing the sprite image. 24 | // The params are a Params instance describing its parameters. 25 | // 26 | // Options: 27 | // startx is the initial percentage (0..100) of the parent width, 28 | // starty is the initial percentage (0..100) of the parent height, 29 | // wrap is whether to wrap around at the end of a cycle. 30 | constructor({figure, params, startx=0, starty=0, wrap=true} = {}) { 31 | if (figure === undefined || params === undefined) { 32 | throw new Error("missing required parameters"); 33 | } 34 | this.#img = figure; 35 | this.#params = params; 36 | const box = figure.parentElement.getBoundingClientRect(); 37 | this.#hcap = 100 * params.fw/box.width; 38 | this.#vcap = 100 * params.fh/box.height; 39 | this.#locx = startx; 40 | this.#locy = starty; 41 | this.#wrap = wrap; 42 | this.moveFigure(); 43 | } 44 | 45 | static pin100(v, cap) { 46 | if (v < -cap) { 47 | return {wrap: true, next: 100-cap}; 48 | } else if (v >= 100) { 49 | return {wrap: true, next: -cap}; 50 | } 51 | return {wrap: false, next: v}; 52 | } 53 | 54 | // Show or hide the figure. 55 | setVisible(ok) { 56 | this.#img.style.visibility = ok ? 'visible' : 'hidden'; 57 | } 58 | 59 | // Move the specified sprite (1-indexed) into the viewport. 60 | setFrame(row, col) { 61 | let rowOffset = (row - 1) * this.#params.fh; 62 | let colOffset = (col - 1) * this.#params.fw; 63 | 64 | this.#img.style.backgroundPosition = `-${colOffset}px -${rowOffset}px`; 65 | } 66 | 67 | // Move the figure to the current location. 68 | moveFigure() { 69 | this.#img.style.left = `${this.#locx}%`; 70 | this.#img.style.top = `${this.#locy}%`; 71 | } 72 | 73 | // Reset the location to the specified percentage offsets (0..100). 74 | resetLocation(px, py) { 75 | this.#locx = px; 76 | this.#locy = py; 77 | this.moveFigure(); 78 | } 79 | 80 | // Update the location by dx, dy and report whether either direction 81 | // reached a boundary. If a boundary was reached and the area allows 82 | // wrapping, the update wraps; otherwise the update is discarded in that 83 | // dimension. 84 | updateLocation(dx, dy) { 85 | let nx = Area.pin100(this.#locx + dx, this.#hcap); 86 | if (!nx.wrap || this.#wrap) { 87 | this.#locx = nx.next; 88 | } 89 | let ny = Area.pin100(this.#locy + dy, this.#vcap); 90 | if (!ny.wrap || this.#wrap) { 91 | this.#locy = ny.next; 92 | } 93 | return nx.wrap || ny.wrap; 94 | } 95 | } 96 | 97 | // A Cycle represents the current state of an animation loop. 98 | // Call update to advance to the next frame of the cycle. 99 | export class Cycle { 100 | #curf; #loop; 101 | 102 | constructor(loop) { 103 | this.#loop = loop; 104 | this.#curf = 0; 105 | } 106 | update(area) { 107 | let col = this.#loop.frames[this.#curf]; 108 | area.setFrame(this.#loop.row, col); 109 | area.moveFigure() 110 | let ok = area.updateLocation(this.#loop.vx/this.#loop.nframes, this.#loop.vy/this.#loop.nframes); 111 | 112 | this.#curf += 1; 113 | if (this.#curf >= this.#loop.nframes) { 114 | this.#curf = 0; 115 | } 116 | return ok; 117 | } 118 | } 119 | 120 | // A Loop represents a sequence of animation frames. 121 | export class Loop { 122 | #vx; #vy; #row; #frames; 123 | 124 | constructor(vx, vy, row, frames) { 125 | this.#vx = vx; 126 | this.#vy = vy; 127 | this.#row = row; 128 | this.#frames = frames; 129 | } 130 | get vx() { return this.#vx; } 131 | get vy() { return this.#vy; } 132 | get row() { return this.#row; } 133 | get frames() { return this.#frames; } 134 | get nframes() { return this.#frames.length; } 135 | } 136 | -------------------------------------------------------------------------------- /server/tailsql/static/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --text-body: #000; 3 | --text-light: #666; 4 | --bg-body: #fff; 5 | --bg-error: #fec; 6 | --bg-error-label: #dba; 7 | --cell-border: #444; 8 | --bg-colname: #eef; 9 | --btn-focus: #ddf; 10 | --body-font: -apple-system, system-ui, Helvetica, Arial, sans-serif; 11 | } 12 | 13 | body { 14 | font-family: var(--body-font); 15 | margin: 2rem 7%; 16 | padding: 0; 17 | font-size: 18px; 18 | background: var(--bg-body); 19 | color: var(--text-body); 20 | min-height: 100vh; 21 | } 22 | 23 | h1 { font-size: 150%; } 24 | 25 | textarea { 26 | font-size: 100%; 27 | font-family: "andale mono", verdana, monospace; 28 | width: 100%; 29 | } 30 | 31 | .meta { 32 | font-size: 80%; 33 | padding: 1rem 0; 34 | display: flex; 35 | flex-wrap: wrap; 36 | justify-content: flex-start; 37 | } 38 | .meta span { 39 | padding: 0.7rem 1rem; 40 | flex: 1 1 min-content; 41 | border: thin solid grey; 42 | } 43 | .meta span:not(:first-of-type) { 44 | text-align: center; 45 | } 46 | .meta span.label { 47 | font-weight: 700; 48 | flex-grow: 0; 49 | background: var(--bg-colname); 50 | } 51 | 52 | .details { 53 | font-size: 80%; 54 | color: var(--text-light); 55 | display: flex; 56 | justify-content: flex-start; 57 | } 58 | .details span:not(:first-of-type) { 59 | border-left: 1px solid black; 60 | padding-left: 1rem; 61 | } 62 | .details span { 63 | padding-right: 1rem; 64 | } 65 | .output div.details { 66 | padding-bottom: 0.5rem; 67 | } 68 | 69 | div.error { 70 | display: flex; 71 | justify-content: flex-start; 72 | } 73 | .error span { 74 | padding: 0.5rem; 75 | flex: 1 1 min-content; 76 | background: var(--bg-error); 77 | } 78 | .error span:first-of-type { 79 | background: var(--bg-error-label); 80 | flex-grow: 0; 81 | font-weight: 700; 82 | } 83 | 84 | .output table { 85 | min-width: 25%; 86 | border-spacing: 1px; 87 | } 88 | .output td, .output th { 89 | border: 1px dotted var(--cell-border); 90 | min-width: fit-content(10%); 91 | font-size: 80%; 92 | padding: 0.2rem 0.5rem; 93 | } 94 | .output th { 95 | padding: 0.5rem; 96 | background: var(--bg-colname); 97 | border-style: solid; 98 | } 99 | 100 | .logo { 101 | display: flex; 102 | justify-content: flex-start; 103 | } 104 | .logo span { 105 | font-size: 200%; 106 | font-weight: bold; 107 | } 108 | 109 | .version { 110 | display: flex; 111 | justify-content: flex-end; 112 | } 113 | .version span { 114 | font-size: 70%; 115 | color: var(--text-light); 116 | } 117 | 118 | .uihint { 119 | display: flex; 120 | justify-content: flex-start; 121 | } 122 | span.uihint { 123 | font-size: 60%; 124 | font-style: italic; 125 | padding: 0.1rem; 126 | color: var(--text-light); 127 | } 128 | 129 | div.action { 130 | display: flex; 131 | justify-content: flex-start; 132 | gap: 1rem; 133 | } 134 | .action span { 135 | font-size: 80%; 136 | } 137 | .action span:last-of-type { 138 | margin-left: auto; 139 | } 140 | .action .ctrl { 141 | appearance: none; 142 | background-color: #FAFBFC; 143 | border: 1px solid rgba(27, 31, 35, 0.15); 144 | border-radius: 6px; 145 | box-shadow: rgba(27, 31, 35, 0.04) 0 1px 0, rgba(255, 255, 255, 0.25) 0 1px 0 inset; 146 | box-sizing: border-box; 147 | color: #24292E; 148 | cursor: pointer; 149 | display: inline-block; 150 | font-family: var(--body-font); 151 | font-size: 14px; 152 | font-weight: 500; 153 | line-height: 20px; 154 | list-style: none; 155 | padding: 6px 16px; 156 | position: relative; 157 | transition: background-color 0.2s cubic-bezier(0.3, 0, 0.5, 1); 158 | user-select: none; 159 | -webkit-user-select: none; 160 | touch-action: manipulation; 161 | vertical-align: middle; 162 | white-space: nowrap; 163 | word-wrap: break-word; 164 | } 165 | .action .ctrl:hover { 166 | background-color: #F3F4F6; 167 | text-decoration: none; 168 | transition-duration: 0.1s; 169 | transform: scale(1.1); 170 | } 171 | .action .ctrl:disabled { 172 | background-color: #FAFBFC; 173 | border-color: rgba(27, 31, 35, 0.15); 174 | color: #959DA5; 175 | cursor: default; 176 | } 177 | .action .ctrl:active { 178 | background-color: #EDEFF2; 179 | box-shadow: rgba(225, 228, 232, 0.2) 0 1px 0 inset; 180 | transition: none 0s; 181 | } 182 | .action .ctrl:focus { 183 | outline: 1px transparent; 184 | background: var(--btn-focus); 185 | } 186 | .action .ctrl:before { 187 | display: none; 188 | } 189 | .action .ctrl::-webkit-details-marker { 190 | display: none; 191 | } 192 | 193 | #nut { 194 | transform: scale(1.5); 195 | position: relative; 196 | height: 32px; 197 | width: 32px; 198 | background: url('nut.png') 0px 0px; 199 | visibility: hidden; 200 | } 201 | -------------------------------------------------------------------------------- /server/tailsql/tailsql.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // Package tailsql implements an HTTP API and "playground" UI for sending SQL 5 | // queries to a collection of local and/or remote databases, and rendering the 6 | // results for human consumption. 7 | // 8 | // # API 9 | // 10 | // The main UI is served from "/", static assets from "/static/". 11 | // The following path and query parameters are understood: 12 | // 13 | // - The q parameter carries an SQL query. The syntax of the query depends on 14 | // the src (see below). See also "Named Queries" below. 15 | // 16 | // - The src parameter names which database to query against. Its values are 17 | // defined when the server is set up. If src is omitted, the first database 18 | // is used as a default. 19 | // 20 | // - "/" serves output as HTML for the UI. In this format the query (q) may 21 | // be empty (no output will be displayed). 22 | // 23 | // - "/json" serves output as JSON objects, one per row. In this format the 24 | // query (q) must be non-empty. 25 | // 26 | // - "/csv" serves output as comma-separated values, the first line giving 27 | // the column names, the remaining lines the rows. In this format the query 28 | // (q) must be non-empty. 29 | // 30 | // - "/meta" serves a JSON blob of metadata about available data sources. 31 | // 32 | // Calls to the /json endpoint must set the Sec-Tailsql header to "1". This 33 | // prevents browser scripts from directing queries to this endpoint. 34 | // 35 | // Calls to /csv must either set Sec-Tailsql to "1" or include a tailsqlQuery=1 36 | // same-site cookie. 37 | // 38 | // Calls to the UI with a non-empty query must include the tailsqlQuery=1 39 | // same-site cookie, which is set when the UI first loads. This averts simple 40 | // cross-site redirection tricks. 41 | // 42 | // # Named Queries 43 | // 44 | // The query processor treats a query of the form "named:" as a named 45 | // query. Named queries are SQL queries pre-defined by the database, to allow 46 | // users to make semantically stable queries without relying on a specific 47 | // schema format. 48 | // 49 | // # Meta Queries 50 | // 51 | // The query processor treats a query "meta:named" as a meta-query to report 52 | // the names and content of all named queries, regardless of source. 53 | package tailsql 54 | 55 | import ( 56 | "context" 57 | "database/sql" 58 | "embed" 59 | "encoding/csv" 60 | "encoding/json" 61 | "errors" 62 | "fmt" 63 | "html/template" 64 | "io" 65 | "net/http" 66 | "strings" 67 | "sync" 68 | "time" 69 | "unicode/utf8" 70 | 71 | "github.com/tailscale/setec/client/setec" 72 | "tailscale.com/client/tailscale/apitype" 73 | "tailscale.com/types/logger" 74 | "tailscale.com/util/httpm" 75 | ) 76 | 77 | //go:embed ui.tmpl 78 | var uiTemplate string 79 | 80 | //go:embed static 81 | var staticFS embed.FS 82 | 83 | var ui = template.Must(template.New("sql").Parse(uiTemplate)) 84 | 85 | // noBrowsersHeader is a header that must be set in requests to the API 86 | // endpoints that are intended to be accessed not from browsers. If this 87 | // header is not set to a non-empty value, those requests will fail. 88 | const noBrowsersHeader = "Sec-Tailsql" 89 | 90 | // siteAccessCookie is a cookie that must be presented with any request from a 91 | // browser that includes a query, and does not have the noBrowsersHeader. 92 | var siteAccessCookie = &http.Cookie{ 93 | Name: "tailsqlQuery", Value: "1", SameSite: http.SameSiteLaxMode, HttpOnly: true, 94 | } 95 | 96 | // contentSecurityPolicy is the CSP value sent for all requests to the UI. 97 | // Adapted from https://owasp.org/www-community/controls/Content_Security_Policy. 98 | const contentSecurityPolicy = `default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; frame-ancestors 'self'; form-action 'self';` 99 | 100 | func requestHasSiteAccess(r *http.Request) bool { 101 | c, err := r.Cookie(siteAccessCookie.Name) 102 | return err == nil && c.Value == siteAccessCookie.Value 103 | } 104 | 105 | func requestHasSecureHeader(r *http.Request) bool { 106 | return r.Header.Get(noBrowsersHeader) != "" 107 | } 108 | 109 | // Server is a server for the tailsql API. 110 | type Server struct { 111 | lc LocalClient 112 | state *localState // local state database (for query logs) 113 | self string // if non-empty, the local state source label 114 | links []UILink 115 | prefix string 116 | rules []UIRewriteRule 117 | authorize func(string, *apitype.WhoIsResponse) error 118 | qcheck func(Query) (Query, error) 119 | qtimeout time.Duration 120 | logf logger.Logf 121 | 122 | mu sync.Mutex 123 | dbs []*setec.Updater[*dbHandle] 124 | } 125 | 126 | // NewServer constructs a new server with the given Options. 127 | func NewServer(opts Options) (*Server, error) { 128 | // Check the validity of the sources, and get any secret names they require 129 | // from the secrets service. If there are any, we also require that a 130 | // secrets service URL is configured. 131 | sec, err := opts.CheckSources() 132 | if err != nil { 133 | return nil, fmt.Errorf("checking sources: %w", err) 134 | } else if len(sec) != 0 && opts.SecretStore == nil { 135 | return nil, fmt.Errorf("have %d named secrets but no secret store", len(sec)) 136 | } 137 | 138 | dbs, err := opts.openSources(context.Background(), opts.SecretStore) 139 | if err != nil { 140 | return nil, fmt.Errorf("opening sources: %w", err) 141 | } 142 | state, err := opts.localState() 143 | if err != nil { 144 | return nil, fmt.Errorf("local state: %w", err) 145 | } 146 | if state != nil && opts.LocalSource != "" { 147 | dbs = append(dbs, setec.StaticUpdater(&dbHandle{ 148 | src: opts.LocalSource, 149 | label: "tailsql local state", 150 | db: state, 151 | named: map[string]string{ 152 | "schema": `select * from sqlite_schema`, 153 | }, 154 | })) 155 | } 156 | 157 | if opts.Metrics != nil { 158 | addMetrics(opts.Metrics) 159 | } 160 | return &Server{ 161 | lc: opts.LocalClient, 162 | state: state, 163 | self: opts.LocalSource, 164 | links: opts.UILinks, 165 | prefix: opts.routePrefix(), 166 | rules: opts.UIRewriteRules, 167 | authorize: opts.authorize(), 168 | qcheck: opts.checkQuery(), 169 | qtimeout: opts.QueryTimeout.Duration(), 170 | logf: opts.logf(), 171 | dbs: dbs, 172 | }, nil 173 | } 174 | 175 | // SetDB adds or replaces the database associated with the specified source in 176 | // s with the given open db and options. See [Server.SetSource]. 177 | func (s *Server) SetDB(source string, db *sql.DB, opts *DBOptions) bool { 178 | return s.SetSource(source, sqlDB{DB: db}, opts) 179 | } 180 | 181 | // SetSource adds or replaces the database associated with the specified source 182 | // in s with the given open db and options. 183 | // 184 | // If a database was already open for the given source, its value is replaced, 185 | // the old database handle is closed, and SetDB reports true. 186 | // 187 | // If no database was already open for the given source, a new source is added 188 | // and SetSource reports false. 189 | func (s *Server) SetSource(source string, db Queryable, opts *DBOptions) bool { 190 | if db == nil { 191 | panic("new database is nil") 192 | } 193 | s.mu.Lock() 194 | defer s.mu.Unlock() 195 | 196 | for _, u := range s.dbs { 197 | if src := u.Get(); src.Source() == source { 198 | src.swap(db, opts) 199 | return true 200 | } 201 | } 202 | s.dbs = append(s.dbs, setec.StaticUpdater(&dbHandle{ 203 | db: db, 204 | src: source, 205 | label: opts.label(), 206 | named: opts.namedQueries(), 207 | })) 208 | return false 209 | } 210 | 211 | // Close closes all the database handles held by s and returns the join of 212 | // their errors. 213 | func (s *Server) Close() error { 214 | dbs := s.getHandles() 215 | errs := make([]error, len(dbs)) 216 | for i, db := range dbs { 217 | errs[i] = db.close() 218 | } 219 | return errors.Join(errs...) 220 | } 221 | 222 | // NewMux constructs an HTTP router for the service. 223 | func (s *Server) NewMux() *http.ServeMux { 224 | mux := http.NewServeMux() 225 | mux.Handle(s.prefix+"/", http.StripPrefix(s.prefix, http.HandlerFunc(s.serveUI))) 226 | 227 | // N.B. We have to strip the prefix back off for the static files, since the 228 | // embedded FS thinks it is rooted at "/". 229 | mux.Handle(s.prefix+"/static/", http.StripPrefix(s.prefix, http.FileServerFS(staticFS))) 230 | return mux 231 | } 232 | 233 | func (s *Server) serveUI(w http.ResponseWriter, r *http.Request) { 234 | if r.Method != httpm.GET { 235 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 236 | return 237 | } 238 | q, err := s.qcheck(Query{ 239 | Source: r.FormValue("src"), 240 | Query: strings.TrimSpace(r.FormValue("q")), 241 | }) 242 | if err != nil { 243 | http.Error(w, err.Error(), http.StatusBadRequest) 244 | return 245 | } 246 | if q.Source == "" { 247 | dbs := s.getHandles() 248 | if len(dbs) != 0 { 249 | q.Source = dbs[0].Source() // default to the first source 250 | } 251 | } 252 | 253 | caller, isAuthorized := s.checkAuth(w, r, q.Source, q.Query) 254 | if !isAuthorized { 255 | authErrorCount.Add(1) 256 | return 257 | } 258 | 259 | switch r.URL.Path { 260 | case "/": 261 | htmlRequestCount.Add(1) 262 | err = s.serveUIInternal(w, r, caller, q) 263 | case "/csv": 264 | csvRequestCount.Add(1) 265 | err = s.serveCSVInternal(w, r, caller, q) 266 | case "/json": 267 | jsonRequestCount.Add(1) 268 | err = s.serveJSONInternal(w, r, caller, q) 269 | case "/meta": 270 | metaRequestCount.Add(1) 271 | err = s.serveMetaInternal(w, r) 272 | default: 273 | badRequestErrorCount.Add(1) 274 | http.Error(w, "not found", http.StatusNotFound) 275 | return 276 | } 277 | if err != nil { 278 | code := errorCode(err) 279 | if code == http.StatusFound { 280 | http.Redirect(w, r, r.URL.String(), code) 281 | return 282 | } else if code >= 400 && code < 500 { 283 | badRequestErrorCount.Add(1) 284 | } else { 285 | internalErrorCount.Add(1) 286 | } 287 | http.Error(w, err.Error(), errorCode(err)) 288 | return 289 | } 290 | } 291 | 292 | // serveUIInternal handles the root GET "/" route. 293 | func (s *Server) serveUIInternal(w http.ResponseWriter, r *http.Request, caller string, q Query) error { 294 | http.SetCookie(w, siteAccessCookie) 295 | w.Header().Set("Content-Security-Policy", contentSecurityPolicy) 296 | w.Header().Set("X-Frame-Options", "DENY") 297 | 298 | // If a non-empty query is present, require either a site access cookie or a 299 | // no-browsers header. 300 | if q.Query != "" && !requestHasSecureHeader(r) && !requestHasSiteAccess(r) { 301 | return statusErrorf(http.StatusFound, "access cookie not found (redirecting)") 302 | } 303 | 304 | w.Header().Set("Content-Type", "text/html") 305 | data := &uiData{ 306 | Query: q.Query, 307 | Source: q.Source, 308 | Sources: s.getHandles(), 309 | Links: s.links, 310 | RoutePrefix: s.prefix, 311 | } 312 | out, err := s.queryContext(r.Context(), caller, q) 313 | if errors.Is(err, errTooManyRows) { 314 | out.More = true 315 | } else if err != nil { 316 | queryErrorCount.Add(1) 317 | msg := err.Error() 318 | data.Error = &msg 319 | return ui.Execute(w, data) 320 | } 321 | 322 | // Don't send too many rows to the UI, the DOM only has one gerbil on its 323 | // wheel. Note we leave NumRows alone, so it can be used to report the real 324 | // number of results the query returned. 325 | const maxUIRows = 500 326 | if out != nil && out.NumRows > maxUIRows { 327 | out.Rows = out.Rows[:maxUIRows] 328 | out.Trunc = true 329 | } 330 | data.Output = out.uiOutput("(null)", s.rules) 331 | return ui.Execute(w, data) 332 | } 333 | 334 | // serveCSVInternal handles the GET /csv route. 335 | func (s *Server) serveCSVInternal(w http.ResponseWriter, r *http.Request, caller string, q Query) error { 336 | if q.Query == "" { 337 | return statusErrorf(http.StatusBadRequest, "no query provided") 338 | } 339 | 340 | // Require either a site access cookie or a no-browsers header. 341 | if !requestHasSecureHeader(r) && !requestHasSiteAccess(r) { 342 | return statusErrorf(http.StatusForbidden, "query access denied") 343 | } 344 | 345 | out, err := s.queryContext(r.Context(), caller, q) 346 | if errors.Is(err, errTooManyRows) { 347 | // fall through to serve what we got 348 | } else if err != nil { 349 | queryErrorCount.Add(1) 350 | return err 351 | } 352 | 353 | return writeResponse(w, r, "text/csv", func(w io.Writer) error { 354 | cw := csv.NewWriter(w) 355 | cw.WriteAll(out.csvOutput()) 356 | cw.Flush() 357 | return cw.Error() 358 | }) 359 | } 360 | 361 | // serveJSONInternal handles the GET /json route. 362 | func (s *Server) serveJSONInternal(w http.ResponseWriter, r *http.Request, caller string, q Query) error { 363 | if q.Query == "" { 364 | return statusErrorf(http.StatusBadRequest, "no query provided") 365 | } 366 | if !requestHasSecureHeader(r) { 367 | return statusErrorf(http.StatusForbidden, "query access denied") 368 | } 369 | 370 | out, err := s.queryContextJSON(r.Context(), caller, q) 371 | if err != nil { 372 | queryErrorCount.Add(1) 373 | return err 374 | } 375 | 376 | return writeResponse(w, r, "application/json", func(w io.Writer) error { 377 | enc := json.NewEncoder(w) 378 | for _, row := range out { 379 | if err := enc.Encode(row); err != nil { 380 | return err 381 | } 382 | } 383 | return nil 384 | }) 385 | } 386 | 387 | // serveMetaInternal handles the GET /meta route. 388 | func (s *Server) serveMetaInternal(w http.ResponseWriter, r *http.Request) error { 389 | w.Header().Set("Content-Type", "application/json") 390 | opts := &Options{UILinks: s.links, QueryTimeout: Duration(s.qtimeout)} 391 | for _, h := range s.getHandles() { 392 | opts.Sources = append(opts.Sources, DBSpec{ 393 | Source: h.Source(), 394 | Label: h.Label(), 395 | Named: h.Named(), 396 | 397 | // N.B. Don't report the URL or the KeyFile location. 398 | }) 399 | } 400 | return json.NewEncoder(w).Encode(struct { 401 | Meta *Options `json:"meta"` 402 | }{Meta: opts}) 403 | } 404 | 405 | // errTooManyRows is a sentinel error reported by queryContextAny when a 406 | // sensible bound on the size of the result set is exceeded. 407 | var errTooManyRows = errors.New("too many rows") 408 | 409 | // queryContextAny executes query using the database handle identified by src. 410 | // The results have whatever types were assigned by the database scanner. 411 | // 412 | // If the number of rows exceeds a sensible limit, it reports errTooManyRows. 413 | // In that case, the result set is still valid, and contains the results that 414 | // were read up to that point. 415 | func (s *Server) queryContext(ctx context.Context, caller string, q Query) (*dbResult, error) { 416 | if q.Query == "" { 417 | return nil, nil 418 | } 419 | 420 | // As a special case, treat a query prefixed with "meta:" as a meta-query to 421 | // be answered regardless of source. 422 | if strings.HasPrefix(q.Query, "meta:") { 423 | return s.queryMeta(ctx, q.Query) 424 | } 425 | 426 | h := s.dbHandleForSource(q.Source) 427 | if h == nil { 428 | return nil, statusErrorf(http.StatusBadRequest, "unknown source %q", q.Source) 429 | } 430 | // Verify that the query does not contain statements we should not ask the 431 | // database to execute. 432 | if err := checkQuerySyntax(q.Query); err != nil { 433 | return nil, statusErrorf(http.StatusBadRequest, "invalid query: %w", err) 434 | } 435 | 436 | const maxRowsPerQuery = 10000 437 | 438 | if s.qtimeout > 0 { 439 | var cancel context.CancelFunc 440 | ctx, cancel = context.WithTimeout(ctx, s.qtimeout) 441 | defer cancel() 442 | } 443 | 444 | return runQuery(ctx, h, 445 | func(fctx context.Context, db Queryable) (_ *dbResult, err error) { 446 | start := time.Now() 447 | var out dbResult 448 | defer func() { 449 | out.Elapsed = time.Since(start) 450 | s.logf("[tailsql] query who=%q src=%q query=%q elapsed=%v err=%v", 451 | caller, q.Source, q.Query, out.Elapsed.Round(time.Millisecond), err) 452 | 453 | // Record successful queries in the persistent log. But don't log 454 | // queries to the state database itself. 455 | if err == nil && q.Source != s.self { 456 | serr := s.state.LogQuery(ctx, caller, q, out.Elapsed) 457 | if serr != nil { 458 | s.logf("[tailsql] WARNING: Error logging query: %v", serr) 459 | } 460 | } 461 | }() 462 | 463 | // Check for a named query. 464 | if name, ok := strings.CutPrefix(q.Query, "named:"); ok { 465 | real, ok := lookupNamedQuery(fctx, name) 466 | if !ok { 467 | return nil, statusErrorf(http.StatusBadRequest, "named query %q not recognized", name) 468 | } 469 | s.logf("[tailsql] resolved named query %q to %#q", name, real) 470 | q.Query = real 471 | } 472 | 473 | rows, err := db.Query(fctx, q.Query) 474 | if err != nil { 475 | return nil, err 476 | } 477 | defer rows.Close() 478 | 479 | cols, err := rows.Columns() 480 | if err != nil { 481 | return nil, fmt.Errorf("listing column names: %w", err) 482 | } 483 | out.Columns = cols 484 | 485 | var tooMany bool 486 | for rows.Next() && !tooMany { 487 | if len(out.Rows) == maxRowsPerQuery { 488 | tooMany = true 489 | break 490 | } else if fctx.Err() != nil { 491 | return nil, fmt.Errorf("scanning row: %w", fctx.Err()) 492 | } 493 | vals := make([]any, len(cols)) 494 | vptr := make([]any, len(cols)) 495 | for i := range cols { 496 | vptr[i] = &vals[i] 497 | } 498 | if err := rows.Scan(vptr...); err != nil { 499 | return nil, fmt.Errorf("scanning row: %w", err) 500 | } 501 | out.Rows = append(out.Rows, vals) 502 | } 503 | if err := rows.Err(); err != nil { 504 | return nil, fmt.Errorf("scanning rows: %w", err) 505 | } 506 | out.NumRows = len(out.Rows) 507 | 508 | if tooMany { 509 | return &out, errTooManyRows 510 | } 511 | return &out, nil 512 | }) 513 | } 514 | 515 | // queryMeta handles meta-queries for internal state. 516 | func (s *Server) queryMeta(ctx context.Context, metaQuery string) (*dbResult, error) { 517 | switch metaQuery { 518 | case "meta:named": 519 | // Report all the named queries 520 | res := &dbResult{ 521 | Columns: []string{"source", "label", "queryName", "sql"}, 522 | } 523 | for _, h := range s.getHandles() { 524 | source, label := h.Source(), h.Label() 525 | for name, sql := range h.Named() { 526 | res.Rows = append(res.Rows, []any{source, label, name, sql}) 527 | } 528 | } 529 | res.NumRows = len(res.Rows) 530 | return res, nil 531 | default: 532 | return nil, statusErrorf(http.StatusBadRequest, "unknown meta-query %q", metaQuery) 533 | } 534 | } 535 | 536 | // queryContextJSON calls s.queryContextAny and, if it succeeds, converts its 537 | // results into values suitable for JSON encoding. 538 | func (s *Server) queryContextJSON(ctx context.Context, caller string, q Query) ([]jsonRow, error) { 539 | if q.Query == "" { 540 | return nil, nil 541 | } 542 | 543 | out, err := s.queryContext(ctx, caller, q) 544 | if errors.Is(err, errTooManyRows) { 545 | // fall through to serve what we got 546 | } else if err != nil { 547 | return nil, err 548 | } 549 | rows := make([]jsonRow, len(out.Rows)) 550 | for i, row := range out.Rows { 551 | jr := make(jsonRow, len(row)) 552 | for j, col := range row { 553 | // Treat human-readable byte slices as strings. 554 | if b, ok := col.([]byte); ok && utf8.Valid(b) { 555 | col = string(b) 556 | } 557 | jr[out.Columns[j]] = col 558 | } 559 | rows[i] = jr 560 | } 561 | return rows, nil 562 | } 563 | 564 | // dbHandleForSource returns the database handle matching the specified src, or 565 | // nil if no matching handle is found. 566 | func (s *Server) dbHandleForSource(src string) *dbHandle { 567 | for _, h := range s.getHandles() { 568 | if h.Source() == src { 569 | return h 570 | } 571 | } 572 | return nil 573 | } 574 | 575 | // checkAuth reports the name of the caller and whether they have access to the 576 | // given source. If the caller does not have access, checkAuth logs an error 577 | // to w and returns false. The reported caller name will be "" if no caller 578 | // can be identified. 579 | func (s *Server) checkAuth(w http.ResponseWriter, r *http.Request, src, query string) (string, bool) { 580 | // If there is no local client, allow everything. 581 | if s.lc == nil { 582 | return "", true 583 | } 584 | whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr) 585 | if err != nil { 586 | http.Error(w, err.Error(), http.StatusInternalServerError) 587 | return "", false 588 | } else if whois == nil { 589 | http.Error(w, "not logged in", http.StatusUnauthorized) 590 | return "", false 591 | } 592 | var caller string 593 | if whois.Node.IsTagged() { 594 | caller = whois.Node.Name 595 | } else { 596 | caller = whois.UserProfile.LoginName 597 | } 598 | 599 | // If the caller wants the UI or metadata, and didn't send a query, allow it. 600 | // The source does not matter when there is no query. 601 | if (r.URL.Path == "/" || r.URL.Path == "/meta") && query == "" { 602 | return caller, true 603 | } 604 | if err := s.authorize(src, whois); err != nil { 605 | http.Error(w, err.Error(), http.StatusForbidden) 606 | return caller, false 607 | } 608 | return caller, true 609 | } 610 | 611 | // getHandles returns the current slice of database handles. THe caller must 612 | // not mutate the slice, but it is safe to read it without a lock. 613 | func (s *Server) getHandles() []*dbHandle { 614 | s.mu.Lock() 615 | defer s.mu.Unlock() 616 | 617 | out := make([]*dbHandle, len(s.dbs)) 618 | 619 | // Check for pending updates. 620 | for i, u := range s.dbs { 621 | out[i] = u.Get() 622 | out[i].tryUpdate() 623 | } 624 | 625 | // It is safe to return the slice because we never remove any elements, new 626 | // data are only ever appended to the end. 627 | return out 628 | } 629 | -------------------------------------------------------------------------------- /server/tailsql/tailsql_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package tailsql_test 5 | 6 | import ( 7 | "context" 8 | "database/sql" 9 | "database/sql/driver" 10 | "errors" 11 | "fmt" 12 | "html" 13 | "html/template" 14 | "io" 15 | "math/rand/v2" 16 | "net/http" 17 | "net/http/httptest" 18 | "net/url" 19 | "path/filepath" 20 | "regexp" 21 | "strings" 22 | "testing" 23 | "time" 24 | 25 | "github.com/tailscale/setec/client/setec" 26 | "github.com/tailscale/setec/setectest" 27 | "github.com/tailscale/tailsql/authorizer" 28 | "github.com/tailscale/tailsql/server/tailsql" 29 | "github.com/tailscale/tailsql/uirules" 30 | "tailscale.com/client/tailscale/apitype" 31 | "tailscale.com/tailcfg" 32 | 33 | _ "embed" 34 | 35 | _ "modernc.org/sqlite" 36 | ) 37 | 38 | //go:embed testdata/init.sql 39 | var initSQL string 40 | 41 | func mustInitSQLite(t *testing.T) (url string, _ *sql.DB) { 42 | t.Helper() 43 | url = "file:" + filepath.Join(t.TempDir(), "test.db") 44 | db, err := sql.Open("sqlite", url) 45 | if err != nil { 46 | t.Fatalf("Open memory db: %v", err) 47 | } 48 | t.Cleanup(func() { db.Close() }) 49 | 50 | if _, err := db.Exec(initSQL); err != nil { 51 | t.Fatalf("Initialize database: %v", err) 52 | } 53 | return url, db 54 | } 55 | 56 | func mustGetRequest(t *testing.T, url string, headers ...string) *http.Request { 57 | t.Helper() 58 | if len(headers)%2 != 0 { 59 | t.Fatal("Invalid header list") 60 | } 61 | req, err := http.NewRequest("GET", url, nil) 62 | if err != nil { 63 | t.Fatalf("NewRequest %q: %v", url, err) 64 | } 65 | for i := 0; i+1 < len(headers); i += 2 { 66 | req.Header.Set(headers[i], headers[i+1]) 67 | } 68 | return req 69 | } 70 | 71 | func mustGetFail(t *testing.T, cli *http.Client, url string, want int, headers ...string) { 72 | t.Helper() 73 | req := mustGetRequest(t, url, headers...) 74 | rsp, err := cli.Do(req) 75 | if err != nil { 76 | t.Fatalf("Get %q: unexpected error: %v", url, err) 77 | } else if got := rsp.StatusCode; got != want { 78 | t.Fatalf("Get %q: got status %v, want %v", url, got, want) 79 | } 80 | } 81 | 82 | func mustGet(t *testing.T, cli *http.Client, url string, headers ...string) []byte { 83 | t.Helper() 84 | req := mustGetRequest(t, url, headers...) 85 | req.AddCookie(&http.Cookie{Name: "tailsqlQuery", Value: "1"}) 86 | rsp, err := cli.Do(req) 87 | if err != nil { 88 | t.Fatalf("Get %q failed: %v", url, err) 89 | } 90 | defer rsp.Body.Close() 91 | if rsp.StatusCode != http.StatusOK { 92 | t.Fatalf("Get %q: status code %d", url, rsp.StatusCode) 93 | } 94 | data, err := io.ReadAll(rsp.Body) 95 | if err != nil { 96 | t.Fatalf("Get %q: read body: %v", url, err) 97 | } 98 | return data 99 | } 100 | 101 | // An ordered list of rewrite rules for rendering text for the UI. 102 | // If a value matches the regular expression, the function is called with the 103 | // original string and the match results to generate a replacement value. 104 | var testUIRules = []tailsql.UIRewriteRule{ 105 | uirules.StripeIDLink, 106 | uirules.FormatSQLSource, 107 | uirules.FormatJSONText, 108 | uirules.LinkURLText, 109 | 110 | // Decorate references to Go documentation. 111 | { 112 | Value: regexp.MustCompile(`^godoc:(.+)$`), 113 | Apply: func(col, s string, match []string) any { 114 | return template.HTML(fmt.Sprintf( 115 | `%[1]s`, match[1], 118 | )) 119 | }, 120 | }, 121 | 122 | // This rule matches what the previous one did, to test that we stop once we 123 | // find a matching rule. 124 | { 125 | Value: regexp.MustCompile(`^godoc:.+$`), 126 | Apply: func(col, s string, match []string) any { 127 | return "some bogus nonsense" 128 | }, 129 | }, 130 | } 131 | 132 | func TestSecrets(t *testing.T) { 133 | // Register a fake driver so we can probe for connection URLs. 134 | // We have to use a new name each time, because there is no way to 135 | // unregister and duplicate names trigger a panic. 136 | driver := new(fakeDriver) 137 | driverName := fmt.Sprintf("%s-driver-%d", t.Name(), rand.Int()) 138 | sql.Register(driverName, driver) 139 | t.Logf("Test driver name is %q", driverName) 140 | 141 | const secretName = "connection-string" 142 | db := setectest.NewDB(t, nil) 143 | db.MustPut(db.Superuser, secretName, "string 1") 144 | 145 | ss := setectest.NewServer(t, db, nil) 146 | hs := httptest.NewServer(ss.Mux) 147 | defer hs.Close() 148 | 149 | opts := tailsql.Options{ 150 | Sources: []tailsql.DBSpec{{ 151 | Source: "test", 152 | Label: "Test Database", 153 | Driver: driverName, 154 | Secret: secretName, 155 | }}, 156 | RoutePrefix: "/tsql", 157 | } 158 | 159 | // Verify we found the expected secret names in the options. 160 | secrets, err := opts.CheckSources() 161 | if err != nil { 162 | t.Fatalf("Invalid sources: %v", err) 163 | } 164 | 165 | tick := setectest.NewFakeTicker() 166 | st, err := setec.NewStore(context.Background(), setec.StoreConfig{ 167 | Client: setec.Client{Server: hs.URL}, 168 | Secrets: secrets, 169 | PollTicker: tick, 170 | }) 171 | if err != nil { 172 | t.Fatalf("Creating setec store: %v", err) 173 | } 174 | opts.SecretStore = st 175 | 176 | ts, err := tailsql.NewServer(opts) 177 | if err != nil { 178 | t.Fatalf("Creating tailsql server: %v", err) 179 | } 180 | ss.Mux.Handle("/tsql/", ts.NewMux()) // so we can call /meta below 181 | defer ts.Close() 182 | 183 | // After opening the server, the database should have the initial secret 184 | // value provided on initialization. 185 | if got, want := driver.OpenedURL, "string 1"; got != want { 186 | t.Errorf("Initial URL: got %q, want %q", got, want) 187 | } 188 | 189 | // Update the secret. 190 | db.MustActivate(db.Superuser, secretName, db.MustPut(db.Superuser, secretName, "string 2")) 191 | tick.Poll() 192 | 193 | // Make the database fetch the latest value. 194 | if _, err := hs.Client().Get(hs.URL + "/tsql/meta"); err != nil { 195 | t.Errorf("Get tailsql meta: %v", err) 196 | } 197 | 198 | // After the update, the database should have the new secret value. 199 | if got, want := driver.OpenedURL, "string 2"; got != want { 200 | t.Errorf("Updated URL: got %q, want %q", got, want) 201 | } 202 | } 203 | 204 | func TestServer(t *testing.T) { 205 | _, db := mustInitSQLite(t) 206 | 207 | const testLabel = "hapax legomenon" 208 | const testAnchor = "wizboggle-gobsprocket" 209 | const testURL = "http://bongo.com?inevitable=true" 210 | fc := new(fakeClient) 211 | fc.isLogged = true 212 | fc.result = &apitype.WhoIsResponse{ 213 | Node: &tailcfg.Node{Name: "fake.ts.net"}, 214 | UserProfile: &tailcfg.UserProfile{ 215 | ID: 1, 216 | LoginName: "someuser@example.com", 217 | DisplayName: "some user", 218 | }, 219 | CapMap: tailcfg.PeerCapMap{ 220 | "tailscale.com/cap/tailsql": []tailcfg.RawMessage{ 221 | `{"src":["*"]}`, 222 | }, 223 | }, 224 | } 225 | s, err := tailsql.NewServer(tailsql.Options{ 226 | LocalClient: fc, 227 | UILinks: []tailsql.UILink{ 228 | {Anchor: testAnchor, URL: testURL}, 229 | }, 230 | CheckQuery: func(q tailsql.Query) (tailsql.Query, error) { 231 | // Rewrite a source named "alias" as a spelling for "main" and add a 232 | // comment at the front. 233 | if q.Source == "alias" { 234 | q.Source = "main" 235 | q.Query = "-- Hello, world\n" + q.Query 236 | } 237 | return tailsql.DefaultCheckQuery(q) 238 | }, 239 | UIRewriteRules: testUIRules, 240 | Authorize: authorizer.ACLGrants(nil), 241 | Logf: t.Logf, 242 | }) 243 | if err != nil { 244 | t.Fatalf("NewServer: unexpected error: %v", err) 245 | } 246 | s.SetDB("main", db, &tailsql.DBOptions{ 247 | Label: testLabel, 248 | NamedQueries: map[string]string{ 249 | "sample": fmt.Sprintf("select '%s'", testLabel), 250 | }, 251 | }) 252 | defer s.Close() 253 | 254 | htest := httptest.NewServer(s.NewMux()) 255 | defer htest.Close() 256 | cli := htest.Client() 257 | 258 | t.Run("UI", func(t *testing.T) { 259 | q := make(url.Values) 260 | q.Set("q", "select location from users where name = 'alice'") 261 | url := htest.URL + "?" + q.Encode() 262 | ui := string(mustGet(t, cli, url)) 263 | 264 | // As a rough smoke test, look for expected substrings. 265 | for _, want := range []string{testAnchor, testURL, testLabel, "amsterdam"} { 266 | if !strings.Contains(ui, want) { 267 | t.Errorf("Missing UI substring %q", want) 268 | } 269 | } 270 | }) 271 | 272 | t.Run("UIDecoration", func(t *testing.T) { 273 | q := url.Values{ 274 | "q": {"select * from misc"}, 275 | "src": {"alias"}, 276 | } 277 | url := htest.URL + "?" + q.Encode() 278 | ui := string(mustGet(t, cli, url)) 279 | 280 | // As a rough smoke test, look for expected substrings. 281 | for _, want := range []string{ 282 | // The query should include its injected comment from the check function. 283 | `-- Hello, world`, 284 | // Stripe IDs should get wrapped in links. 285 | `{"json":true}`, 290 | // SQL should be formatted verbatim. 291 | `
CREATE TABLE misc (x);
`, 292 | // Go documentation should link to godoc.org. 293 | `
`, 297 | } { 298 | if !strings.Contains(ui, want) { 299 | t.Errorf("Missing UI substring %q", want) 300 | } 301 | } 302 | if t.Failed() { 303 | t.Logf("UI output:\n%s", ui) 304 | } 305 | }) 306 | 307 | t.Run("JSON", func(t *testing.T) { 308 | q := make(url.Values) 309 | q.Set("q", "select name from users where title = 'mascot'") 310 | q.Set("src", "main") 311 | url := htest.URL + "/json?" + q.Encode() 312 | 313 | const want = `{"name":"amelie"}` 314 | got := strings.TrimSpace(string(mustGet(t, cli, url, "sec-tailsql", "1"))) 315 | if got != want { 316 | t.Errorf("JSON result: got %q, want %q", got, want) 317 | } 318 | }) 319 | 320 | t.Run("JSON_noHeader", func(t *testing.T) { 321 | q := url.Values{"q": {"select * from whatever"}} 322 | url := htest.URL + "/json?" + q.Encode() 323 | 324 | mustGetFail(t, cli, url, http.StatusForbidden) // no forbidden header 325 | }) 326 | 327 | t.Run("CSV", func(t *testing.T) { 328 | q := make(url.Values) 329 | q.Set("q", "select count(*) n from users") 330 | url := htest.URL + "/csv?" + q.Encode() 331 | 332 | const want = "n\n10\n" // one column, one row plus header 333 | got := string(mustGet(t, cli, url)) 334 | if got != want { 335 | t.Errorf("CSV result: got %q, want %q", got, want) 336 | } 337 | }) 338 | 339 | t.Run("CSV_noHeader", func(t *testing.T) { 340 | q := url.Values{"q": {"select * from whatever"}} 341 | url := htest.URL + "/csv?" + q.Encode() 342 | 343 | mustGetFail(t, cli, url, http.StatusForbidden) // no forbidden header 344 | }) 345 | 346 | t.Run("Named", func(t *testing.T) { 347 | q := url.Values{"q": {"named:sample"}} 348 | url := htest.URL + "?" + q.Encode() 349 | got := string(mustGet(t, cli, url)) 350 | if !strings.Contains(got, testLabel) { 351 | t.Errorf("Missing result substring %q:\n%s", testLabel, got) 352 | } 353 | }) 354 | 355 | t.Run("UnknownNamed", func(t *testing.T) { 356 | q := url.Values{"q": {"named:nonesuch"}} 357 | url := htest.URL + "?" + q.Encode() 358 | got := string(mustGet(t, cli, url)) 359 | wantError := html.EscapeString(`named query "nonesuch" not recognized`) 360 | if !strings.Contains(got, wantError) { 361 | t.Errorf("Missing result substring %q:\n%s", wantError, got) 362 | } 363 | }) 364 | 365 | t.Run("OverLongQuery", func(t *testing.T) { 366 | q := url.Values{"q": {fmt.Sprintf("select '%s';", strings.Repeat("f", 4000))}} 367 | url := htest.URL + "/csv?" + q.Encode() 368 | 369 | mustGetFail(t, cli, url, http.StatusBadRequest, "sec-tailsql", "1") // query too long 370 | }) 371 | } 372 | 373 | // A fakeClient implements the tailsql.LocalClient interface. 374 | // It reports success if its argument matches addr; otherwise it reports an 375 | // error. 376 | type fakeClient struct { 377 | isLogged bool 378 | err error 379 | result *apitype.WhoIsResponse 380 | } 381 | 382 | func (f *fakeClient) WhoIs(_ context.Context, addr string) (*apitype.WhoIsResponse, error) { 383 | if f.err != nil { 384 | return nil, f.err 385 | } else if f.isLogged { 386 | return f.result, nil 387 | } 388 | return nil, nil 389 | } 390 | 391 | func TestAuth(t *testing.T) { 392 | const testUser = 1234567 393 | var ( 394 | taggedNode = &tailcfg.Node{Name: "fake.ts.net", Tags: []string{"tag:nonesuch"}} 395 | untaggedNode = &tailcfg.Node{Name: "fake.ts.net"} 396 | userProfile1 = &tailcfg.UserProfile{ 397 | ID: testUser, 398 | LoginName: "user@fakesite.example.com", 399 | DisplayName: "Hermanita Q. Testwaller", 400 | } 401 | ) 402 | 403 | _, db := mustInitSQLite(t) 404 | 405 | // An initially-empty fake client, which we will update between tests. 406 | fc := new(fakeClient) 407 | 408 | s, err := tailsql.NewServer(tailsql.Options{ 409 | LocalClient: fc, 410 | 411 | Authorize: func(src string, wr *apitype.WhoIsResponse) error { 412 | if wr.Node.IsTagged() && len(wr.CapMap) == 0 { 413 | return errors.New("tagged node without capabilities rejected") 414 | } else if src == "main" { 415 | return nil // no authorization required for "main" 416 | } else if wr.UserProfile.ID == testUser { 417 | return nil // this user is explicitly allowed 418 | } else { 419 | return errors.New("authorization denied") // fail closed 420 | } 421 | }, 422 | }) 423 | if err != nil { 424 | t.Fatalf("NewServer: unexpected error: %v", err) 425 | } 426 | s.SetDB("main", db, &tailsql.DBOptions{ 427 | Label: "Main test data", 428 | }) 429 | defer s.Close() 430 | 431 | htest := httptest.NewServer(s.NewMux()) 432 | defer htest.Close() 433 | cli := htest.Client() 434 | 435 | mustCall := func(t *testing.T, url string, want int) { 436 | req, err := http.NewRequest("GET", url, nil) 437 | if err != nil { 438 | t.Fatalf("New request for %q: %v", url, err) 439 | } 440 | req.Header.Set("Sec-Tailsql", "ok") 441 | rsp, err := cli.Do(req) 442 | if err != nil { 443 | t.Fatalf("Get %q: unexpected error: %v", url, err) 444 | } 445 | defer rsp.Body.Close() 446 | if got := rsp.StatusCode; got != want { 447 | t.Errorf("Get %q: got %d, want %d", url, got, want) 448 | } 449 | } 450 | testQuery := url.Values{"q": {"select 'ok'"}}.Encode() 451 | 452 | // Check for a user who is not logged in. 453 | t.Run("NotLogged", func(t *testing.T) { 454 | mustCall(t, htest.URL, http.StatusUnauthorized) 455 | }) 456 | 457 | fc.isLogged = true 458 | fc.result = &apitype.WhoIsResponse{ 459 | Node: taggedNode, 460 | UserProfile: userProfile1, 461 | } 462 | 463 | // A tagged node cannot query a source it's not granted. 464 | // However, anyone can get the UI with no query. 465 | t.Run("TaggedNode/Query", func(t *testing.T) { 466 | mustCall(t, htest.URL+"?"+testQuery, http.StatusForbidden) 467 | }) 468 | t.Run("TaggedNode/UI", func(t *testing.T) { 469 | mustCall(t, htest.URL, http.StatusOK) 470 | }) 471 | 472 | fc.result.Node = untaggedNode 473 | 474 | // Check for a valid user who is authorized. 475 | t.Run("ValidAuth", func(t *testing.T) { 476 | mustCall(t, htest.URL+"?"+testQuery, http.StatusOK) 477 | }) 478 | 479 | fc.result.UserProfile.ID = 678910 480 | 481 | // Check for a valid user who is not authorized. 482 | t.Run("ValidUnauth", func(t *testing.T) { 483 | mustCall(t, htest.URL+"?src=other&"+testQuery, http.StatusForbidden) 484 | }) 485 | } 486 | 487 | // Verify that context cancellation is correctly propagated. 488 | // This test is specific to SQLite, but the point is to make sure the context 489 | // plumbing in tailsql is correct. 490 | func TestQueryTimeout(t *testing.T) { 491 | _, db := mustInitSQLite(t) 492 | 493 | // This test query runs forever until interrupted. 494 | const testQuery = `WITH RECURSIVE inf(n) AS ( 495 | SELECT 1 496 | UNION ALL 497 | SELECT n+1 FROM inf 498 | ) SELECT * FROM inf WHERE n = 0` 499 | 500 | done := make(chan struct{}) 501 | go func() { 502 | defer close(done) 503 | 504 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 505 | defer cancel() 506 | 507 | rows, err := db.QueryContext(ctx, testQuery) 508 | if err == nil { 509 | t.Errorf("QueryContext: got rows=%v, want error", rows) 510 | } else if !errors.Is(err, context.DeadlineExceeded) { 511 | t.Errorf("Got error %v, wanted %v", err, context.DeadlineExceeded) 512 | } 513 | }() 514 | 515 | select { 516 | case <-done: 517 | // OK 518 | case <-time.After(30 * time.Second): 519 | t.Fatal("Timeout waiting for query to end") 520 | } 521 | } 522 | 523 | func TestQueryable(t *testing.T) { 524 | _, db := mustInitSQLite(t) 525 | 526 | s, err := tailsql.NewServer(tailsql.Options{ 527 | Sources: []tailsql.DBSpec{{ 528 | Source: "quux", 529 | Label: "Happy fun database", 530 | DB: sqlDB{DB: db}, 531 | 532 | // Note: Omit Driver to exercise that this is allowed when a 533 | // programmatic data source is given. 534 | }}, 535 | }) 536 | if err != nil { 537 | t.Fatalf("NewServer: %v", err) 538 | } 539 | defer s.Close() 540 | 541 | htest := httptest.NewServer(s.NewMux()) 542 | defer htest.Close() 543 | cli := htest.Client() 544 | 545 | t.Run("Smoke", func(t *testing.T) { 546 | const testProbe = "mindlesprocket" 547 | q := url.Values{ 548 | "src": {"quux"}, 549 | "q": {fmt.Sprintf(`select '%s'`, testProbe)}, 550 | } 551 | rsp := string(mustGet(t, cli, htest.URL+"/csv?"+q.Encode())) 552 | if !strings.Contains(rsp, testProbe) { 553 | t.Errorf("Query failed: got %q, want %q", rsp, testProbe) 554 | } 555 | }) 556 | } 557 | 558 | type sqlDB struct{ *sql.DB } 559 | 560 | func (s sqlDB) Query(ctx context.Context, query string, params ...any) (tailsql.RowSet, error) { 561 | return s.DB.QueryContext(ctx, query, params...) 562 | } 563 | 564 | func TestRoutePrefix(t *testing.T) { 565 | s, err := tailsql.NewServer(tailsql.Options{ 566 | RoutePrefix: "/sub/dir", 567 | }) 568 | if err != nil { 569 | t.Fatalf("NewServer: unexpected error: %v", err) 570 | } 571 | defer s.Close() 572 | 573 | hs := httptest.NewServer(s.NewMux()) 574 | defer hs.Close() 575 | cli := hs.Client() 576 | 577 | t.Run("NotFound", func(t *testing.T) { 578 | rsp, err := cli.Get(hs.URL + "/static/logo.svg") 579 | if err != nil { 580 | t.Fatalf("Get: unexpected error: %v", err) 581 | } 582 | _, err = io.ReadAll(rsp.Body) 583 | rsp.Body.Close() 584 | if err != nil { 585 | t.Errorf("Read body: %v", err) 586 | } 587 | if code := rsp.StatusCode; code != http.StatusNotFound { 588 | t.Errorf("Get: got response %v, want %v", code, http.StatusNotFound) 589 | } 590 | }) 591 | 592 | t.Run("OK", func(t *testing.T) { 593 | txt := string(mustGet(t, cli, hs.URL+"/sub/dir/static/logo.svg")) 594 | if !strings.HasPrefix(txt, " 2 | 3 | Tailscale SQL Playground 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 |
{{with .Version}} 15 |
16 | Version: {{.}} 17 |
{{end}} 18 |
19 |
20 | Hint: Use shift-enter to submit your query 21 |
22 | 23 | 24 | 27 |
28 |
29 |
30 | {{with .Links}}
{{end}} 34 | 35 | {{with .Error}} 36 |
37 | Error: 38 | {{.}} 39 |
{{end -}} 40 | {{with .Output}} 41 |
42 |
43 |
44 | Query time: {{.Elapsed}} 45 | {{.NumRows}} rows{{if .More}} fetched (additional rows not loaded){{else}} total{{end}}{{if .Trunc}} 46 | (display is truncated){{end}} 47 |
48 | 49 | {{range .Columns}} 50 | {{end}} 51 | 52 | {{range .Rows -}} 53 | {{range .}} 54 | {{end}} 55 | {{end}} 56 |
{{.}}
{{.}}
57 | {{end -}} 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /server/tailsql/utils.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package tailsql 5 | 6 | import ( 7 | "context" 8 | "encoding/base64" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "net/http" 13 | "regexp" 14 | "strings" 15 | "time" 16 | "unicode/utf8" 17 | 18 | "github.com/klauspost/compress/zstd" 19 | "tailscale.com/client/tailscale/apitype" 20 | "tailscale.com/tsweb" 21 | "tailscale.com/version" 22 | ) 23 | 24 | // LocalClient is the subset of the tailscale.LocalClient interface required by 25 | // the Server. It is defined here so that the caller can substitute a fake for 26 | // testing and debugging. 27 | type LocalClient interface { 28 | WhoIs(context.Context, string) (*apitype.WhoIsResponse, error) 29 | } 30 | 31 | // uiData is the concrete type of the data value passed to the UI template. 32 | type uiData struct { 33 | Query string // the original query 34 | Sources []*dbHandle // the available databases 35 | Source string // the selected source 36 | Output *dbResult // query results (may be nil) 37 | Error *string // error results (may be nil) 38 | Links []UILink // static UI links 39 | RoutePrefix string // for links to the API and static files 40 | } 41 | 42 | // Version reports the version string of the currently running binary. 43 | func (u *uiData) Version() string { return version.Long() } 44 | 45 | // jsonRow represents an output row as a JSON value. 46 | type jsonRow map[string]any 47 | 48 | // dbResult represents the result of an SQL query in generic form. 49 | type dbResult struct { 50 | Elapsed time.Duration // how long the query took 51 | Columns []string // column names, in order of retrieval 52 | Rows [][]any // rows, in the types assigned by the scanner 53 | NumRows int // total number of rows reported 54 | Trunc bool // whether the display was truncated 55 | More bool // whether there are more results in the database 56 | } 57 | 58 | // uiOutput modifies the column values of r in-place to render the values as 59 | // strings suitable for inclusion in an HTML template. It returns r to permit 60 | // chaining. The null string is substituted for NULL-valued columns. 61 | func (r *dbResult) uiOutput(null string, uiRules []UIRewriteRule) *dbResult { 62 | if r == nil || len(r.Columns) == 0 { 63 | return r 64 | } 65 | 66 | // Round elapsed time for human legibilitiy. 67 | r.Elapsed = r.Elapsed.Round(100 * time.Microsecond) 68 | 69 | for _, row := range r.Rows { 70 | nextCol: 71 | for i, col := range row { 72 | s := valueToString(col, null) 73 | 74 | // Check for rewrite rules. 75 | colName := r.Columns[i] 76 | for _, rule := range uiRules { 77 | ok, newValue := rule.CheckApply(colName, s) 78 | if ok { 79 | row[i] = newValue 80 | continue nextCol 81 | } 82 | } 83 | row[i] = s 84 | } 85 | } 86 | return r 87 | } 88 | 89 | // csvOutput returns a CSV representation of the result, suitable for use with 90 | // a csv.Writer. 91 | func (r *dbResult) csvOutput() [][]string { 92 | if len(r.Columns) == 0 { 93 | return nil 94 | } 95 | rows := make([][]string, 1+len(r.Rows)) // +1 for the header 96 | rows[0] = r.Columns 97 | for i, row := range r.Rows { 98 | srow := make([]string, len(row)) 99 | for j, col := range row { 100 | srow[j] = valueToString(col, "") 101 | } 102 | rows[i+1] = srow 103 | } 104 | return rows 105 | } 106 | 107 | // valueToString assigns a sensible string representation to a database value. 108 | func valueToString(v any, null string) string { 109 | switch t := v.(type) { 110 | case nil: 111 | return null 112 | case []byte: 113 | // If the value looks text-like, return it as a string; otherwise encode 114 | // it as base64 to avert mojibake. 115 | if isBinaryData(t) && !utf8.Valid(t) { 116 | return base64.RawStdEncoding.EncodeToString(t) 117 | } 118 | return string(t) 119 | case string: 120 | return t 121 | case time.Time: 122 | // For single-day timestamps, trim off the time portion so the output 123 | // looks nicer on the page. 124 | ts := t.UTC().Format(time.RFC3339) 125 | pre, _, ok := strings.Cut(ts, " 00:00:00") 126 | if ok { 127 | return pre 128 | } 129 | return ts 130 | default: 131 | return fmt.Sprint(t) 132 | } 133 | } 134 | 135 | // isBinaryData reports whether data contains byte values outside the ASCII 136 | // range, or non-printable controls. 137 | func isBinaryData(data []byte) bool { 138 | for _, b := range data { 139 | switch { 140 | case b > '~': // including DEL 141 | return true 142 | case b >= ' ', b == '\t', b == '\n', b == '\r': 143 | // controls HT, LF, and CR are OK 144 | default: 145 | return true 146 | } 147 | } 148 | return false 149 | } 150 | 151 | // runQuery executes query using h.WithLock, and returns its results. 152 | func runQuery[T any](ctx context.Context, h *dbHandle, run func(context.Context, Queryable) (T, error)) (T, error) { 153 | var out T 154 | err := h.WithLock(ctx, func(fctx context.Context, q Queryable) error { 155 | var err error 156 | out, err = run(fctx, q) 157 | return err 158 | }) 159 | 160 | // Check for updates. If this is the last query in flight and there is an 161 | // update, this will succeed; otherwise some other query holds the lock and 162 | // will succeed later. 163 | h.tryUpdate() 164 | return out, err 165 | } 166 | 167 | // writeResponse calls f to write an HTTP response body of contentType. It 168 | // handles compressing the response with zstd if the request specifies it as an 169 | // accepted encoding. 170 | func writeResponse(w http.ResponseWriter, req *http.Request, contentType string, f func(w io.Writer) error) error { 171 | w.Header().Set("Content-Type", contentType) 172 | w.Header().Set("Vary", "Accept-Encoding") 173 | if !tsweb.AcceptsEncoding(req, "zstd") { 174 | return f(w) 175 | } 176 | zw, err := zstd.NewWriter(w) 177 | if err != nil { 178 | return fmt.Errorf("create compressor: %w", err) 179 | } 180 | w.Header().Set("Content-Encoding", "zstd") 181 | if err := f(zw); err != nil { 182 | zw.Close() // discard 183 | return err 184 | } 185 | return zw.Close() 186 | } 187 | 188 | // statusError is an error that carries an HTTP status code. 189 | type statusError struct { 190 | code int 191 | err error 192 | } 193 | 194 | func (s statusError) Error() string { 195 | return fmt.Sprintf("[%d] %s", s.code, s.err) 196 | } 197 | 198 | func (s statusError) Unwrap() error { return s.err } 199 | 200 | func statusErrorf(code int, msg string, args ...any) error { 201 | return statusError{code: code, err: fmt.Errorf(msg, args...)} 202 | } 203 | 204 | // errorCode extracts an HTTP status code from an error. If err is a 205 | // statusError, it returns the enclosed code; otherwise it defaults to 206 | // http.StatusInternalServerError. 207 | func errorCode(err error) int { 208 | var s statusError 209 | if errors.As(err, &s) { 210 | return s.code 211 | } 212 | return http.StatusInternalServerError 213 | } 214 | 215 | // checkQuerySyntax reports whether query is safe to send to the database. 216 | // 217 | // A read-only SQLite database will correctly report errors for operations that 218 | // modify the database or its schema if it is opened read-only. However, the 219 | // ATTACH and DETACH verbs modify only the connection, permitting the caller to 220 | // mention any database accessible from the filesystem. Similarly, VACUUM INTO 221 | // can be run even on a read-only database, so don't allow it in any form. 222 | func checkQuerySyntax(query string) error { 223 | for _, tok := range sqlTokens(query) { 224 | switch tok { 225 | case "ATTACH", "DETACH", "TEMP", "TEMPORARY", "VACUUM": 226 | return fmt.Errorf("statement %q is not allowed", tok) 227 | } 228 | } 229 | return nil 230 | } 231 | 232 | // sqlRE is a very approximate lexical matcher for SQL tokens. 233 | var sqlRE = regexp.MustCompile(`(?m)\w+|('([^\']|'')*')|("([^\"]|"")*")|(--.*(\n|$))|\S+`) 234 | 235 | // sqlTokens performs a lightweight and approximate lexical analysis of query 236 | // as an SQL input. It discards string literals and comments, and returns the 237 | // remaining "tokens" normalized to uppercase. 238 | func sqlTokens(query string) []string { 239 | var tok []string 240 | ms := sqlRE.FindAllStringSubmatch(query, -1) 241 | for _, m := range ms { 242 | if strings.HasPrefix(m[0], "--") || 243 | strings.HasPrefix(m[0], "'") || 244 | strings.HasPrefix(m[0], `"`) { 245 | continue 246 | } 247 | tok = append(tok, strings.ToUpper(m[0])) 248 | } 249 | return tok 250 | } 251 | -------------------------------------------------------------------------------- /tools/deps.go: -------------------------------------------------------------------------------- 1 | //go:build go_mod_tidy_deps 2 | 3 | // Package tools is a pseudo-package for tracking Go tool dependencies 4 | // that are not needed for build or test. 5 | package tools 6 | 7 | import ( 8 | // Used by CI. 9 | _ "honnef.co/go/tools/cmd/staticcheck" 10 | ) 11 | -------------------------------------------------------------------------------- /tools/update-oss.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Usage: update-oss.sh 4 | # 5 | # Update the version of the tailscale.com module to be in sync with the version 6 | # used by the specified repository. 7 | # 8 | # Requires: gh, git, go, jq 9 | # 10 | set -euo pipefail 11 | 12 | # The repository path to compare to. 13 | repo=tailscale/corp 14 | 15 | # The module to update. 16 | module=tailscale.com 17 | 18 | cd "$(git rev-parse --show-toplevel)" 19 | if ! git diff --quiet ; then 20 | echo ">> WARNING: The working directory is not clean." 1>&2 21 | echo " Commit or stash your changes first." 1>&2 22 | git diff --stat 23 | exit 1 24 | fi 25 | git checkout --quiet main && git pull --rebase --quiet 26 | 27 | have="$(go list -f '{{.Version}}' -m "$module" | cut -d- -f3)" 28 | want="$( 29 | gh api -q '.content|@base64d' repos/"${repo}"/contents/go.mod | 30 | grep -E "\b${module} " | cut -d' ' -f2 | cut -d- -f3 31 | )" 32 | if [[ "$have" = "$want" ]] ; then 33 | echo "Module $module is up-to-date at commit $have" 1>&2 34 | exit 0 35 | fi 36 | 37 | go get "$module"@"$want" 38 | go mod edit --toolchain=none 39 | go mod tidy 40 | echo "Module $module updated to commit $want" 1>&2 41 | echo "(you must commit and push this change to persist it)" 1>&2 42 | -------------------------------------------------------------------------------- /uirules/uirules.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // Package uirules defines useful UIRewriteRule values for use with the tailsql 5 | // server package. 6 | package uirules 7 | 8 | import ( 9 | "encoding/json" 10 | "fmt" 11 | "html/template" 12 | "net/url" 13 | "regexp" 14 | 15 | "github.com/tailscale/tailsql/server/tailsql" 16 | ) 17 | 18 | // StripeIDLink is a UI rewrite rule that wraps Stripe customer and invoice ID 19 | // strings to the Stripe dashboard. 20 | var StripeIDLink = tailsql.UIRewriteRule{ 21 | Value: regexp.MustCompile(`^(cus|in|sub)_[a-zA-Z0-9]+$`), 22 | Apply: func(col, s string, m []string) any { 23 | var kind string 24 | switch m[1] { 25 | case "cus": 26 | kind = "customer" 27 | case "in": 28 | kind = "invoice" 29 | case "sub": 30 | kind = "subscription" 31 | default: 32 | return s 33 | } 34 | return template.HTML(fmt.Sprintf( 35 | `%[1]s`, 38 | s, kind)) 39 | }, 40 | } 41 | 42 | // FormatSQLSource is a UI rewrite rule to render SQL query text preformatted. 43 | var FormatSQLSource = tailsql.UIRewriteRule{ 44 | Value: regexp.MustCompile(`(?is)\b(select\s+.*?(;\s*$|from\b)|create\s+(table|view)\b)`), 45 | Apply: func(col, s string, _ []string) any { 46 | esc := template.HTMLEscapeString(s) 47 | return template.HTML(fmt.Sprintf(`
%s
`, esc)) 48 | }, 49 | } 50 | 51 | // FormatJSONText is a UI rewrite rule to render JSON text preformatted. 52 | var FormatJSONText = tailsql.UIRewriteRule{ 53 | Value: regexp.MustCompile(`null|true|false|[{},:\[\]]`), 54 | Apply: func(col, s string, _ []string) any { 55 | if json.Valid([]byte(s)) { 56 | esc := template.HTMLEscapeString(s) 57 | return template.HTML(fmt.Sprintf(`%s`, esc)) 58 | } 59 | return nil // fail over to the next rule 60 | }, 61 | } 62 | 63 | // LinkURLText is a UI rewrite rule to add links for URL-shaped results. 64 | var LinkURLText = tailsql.UIRewriteRule{ 65 | Value: regexp.MustCompile(`^https?://\S+$`), 66 | Apply: func(col, s string, _ []string) any { 67 | if u, err := url.Parse(s); err == nil { 68 | return template.HTML(fmt.Sprintf(`%s`, 69 | u.String(), template.HTMLEscaper(s))) 70 | } 71 | return s 72 | }, 73 | } 74 | -------------------------------------------------------------------------------- /uirules/uirules_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package uirules_test 5 | 6 | import ( 7 | "html/template" 8 | "testing" 9 | 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/tailscale/tailsql/server/tailsql" 12 | "github.com/tailscale/tailsql/uirules" 13 | ) 14 | 15 | func TestRules(t *testing.T) { 16 | tests := []struct { 17 | rule tailsql.UIRewriteRule 18 | input string 19 | ok bool 20 | want any 21 | }{ 22 | {uirules.StripeIDLink, "cus_abcdef", true, 23 | template.HTML(`` + 25 | `cus_abcdef`)}, 26 | {uirules.StripeIDLink, "in_12345", true, 27 | template.HTML(`` + 29 | `in_12345`)}, 30 | {uirules.StripeIDLink, "nonesuch", false, nil}, 31 | 32 | {uirules.FormatSQLSource, "select x < 3;", true, 33 | template.HTML(`
select x < 3;
`)}, 34 | {uirules.FormatSQLSource, "select x <> 3 from y", true, 35 | template.HTML(`
select x <> 3 from y
`)}, 36 | {uirules.FormatSQLSource, "create table t (x);", true, 37 | template.HTML(`
create table t (x);
`)}, 38 | {uirules.FormatSQLSource, "it is easier to create than destroy", false, nil}, 39 | 40 | {uirules.FormatJSONText, "invalid null", false, nil}, 41 | {uirules.FormatJSONText, "[null]", true, template.HTML(`[null]`)}, 42 | {uirules.FormatJSONText, `{"incomplete":`, false, nil}, 43 | {uirules.FormatJSONText, "unrelated", false, nil}, 44 | {uirules.FormatJSONText, `{"x":"&"}`, true, 45 | template.HTML(`{"x":"&"}`)}, 46 | 47 | {uirules.LinkURLText, "unrelated", false, nil}, 48 | {uirules.LinkURLText, "s3://non-http.src", false, nil}, 49 | {uirules.LinkURLText, "https://invalid link", false, nil}, 50 | {uirules.LinkURLText, "http://localhost:8080/foo?bar", true, 51 | template.HTML(`http://localhost:8080/foo?bar`)}, 53 | } 54 | for _, tc := range tests { 55 | ok, got := tc.rule.CheckApply("colName", tc.input) 56 | if ok != tc.ok { 57 | t.Errorf("CheckApply rule=%v OK: got %v, want %v", tc.rule, ok, tc.ok) 58 | } 59 | if diff := cmp.Diff(tc.want, got); diff != "" { 60 | t.Errorf("CheckApply rule=%v result (-want, +got):\n%s", tc.rule, diff) 61 | } 62 | } 63 | } 64 | --------------------------------------------------------------------------------