summaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
Diffstat (limited to 'client')
-rw-r--r--client/tailscale/apitype/controltype.go38
-rw-r--r--client/tailscale/dns.go466
-rw-r--r--client/tailscale/example/servetls/servetls.go56
-rw-r--r--client/tailscale/keys.go332
-rw-r--r--client/tailscale/routes.go190
-rw-r--r--client/tailscale/tailnet.go84
-rw-r--r--client/web/qnap.go254
-rw-r--r--client/web/src/assets/icons/arrow-right.svg8
-rw-r--r--client/web/src/assets/icons/arrow-up-circle.svg10
-rw-r--r--client/web/src/assets/icons/check-circle.svg8
-rw-r--r--client/web/src/assets/icons/check.svg6
-rw-r--r--client/web/src/assets/icons/chevron-down.svg6
-rw-r--r--client/web/src/assets/icons/eye.svg22
-rw-r--r--client/web/src/assets/icons/search.svg8
-rw-r--r--client/web/src/assets/icons/tailscale-icon.svg36
-rw-r--r--client/web/src/assets/icons/tailscale-logo.svg40
-rw-r--r--client/web/src/assets/icons/user.svg8
-rw-r--r--client/web/src/assets/icons/x-circle.svg10
-rw-r--r--client/web/synology.go118
19 files changed, 850 insertions, 850 deletions
diff --git a/client/tailscale/apitype/controltype.go b/client/tailscale/apitype/controltype.go
index 9a623be31..a9a76065f 100644
--- a/client/tailscale/apitype/controltype.go
+++ b/client/tailscale/apitype/controltype.go
@@ -1,19 +1,19 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-package apitype
-
-type DNSConfig struct {
- Resolvers []DNSResolver `json:"resolvers"`
- FallbackResolvers []DNSResolver `json:"fallbackResolvers"`
- Routes map[string][]DNSResolver `json:"routes"`
- Domains []string `json:"domains"`
- Nameservers []string `json:"nameservers"`
- Proxied bool `json:"proxied"`
- TempCorpIssue13969 string `json:"TempCorpIssue13969,omitempty"`
-}
-
-type DNSResolver struct {
- Addr string `json:"addr"`
- BootstrapResolution []string `json:"bootstrapResolution,omitempty"`
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package apitype
+
+type DNSConfig struct {
+ Resolvers []DNSResolver `json:"resolvers"`
+ FallbackResolvers []DNSResolver `json:"fallbackResolvers"`
+ Routes map[string][]DNSResolver `json:"routes"`
+ Domains []string `json:"domains"`
+ Nameservers []string `json:"nameservers"`
+ Proxied bool `json:"proxied"`
+ TempCorpIssue13969 string `json:"TempCorpIssue13969,omitempty"`
+}
+
+type DNSResolver struct {
+ Addr string `json:"addr"`
+ BootstrapResolution []string `json:"bootstrapResolution,omitempty"`
+}
diff --git a/client/tailscale/dns.go b/client/tailscale/dns.go
index f198742b3..12b9e15c8 100644
--- a/client/tailscale/dns.go
+++ b/client/tailscale/dns.go
@@ -1,233 +1,233 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-//go:build go1.19
-
-package tailscale
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "net/http"
-
- "tailscale.com/client/tailscale/apitype"
-)
-
-// DNSNameServers is returned when retrieving the list of nameservers.
-// It is also the structure provided when setting nameservers.
-type DNSNameServers struct {
- DNS []string `json:"dns"` // DNS name servers
-}
-
-// DNSNameServersPostResponse is returned when setting the list of DNS nameservers.
-//
-// It includes the MagicDNS status since nameservers changes may affect MagicDNS.
-type DNSNameServersPostResponse struct {
- DNS []string `json:"dns"` // DNS name servers
- MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
-}
-
-// DNSSearchpaths is the list of search paths for a given domain.
-type DNSSearchPaths struct {
- SearchPaths []string `json:"searchPaths"` // DNS search paths
-}
-
-// DNSPreferences is the preferences set for a given tailnet.
-//
-// It includes MagicDNS which can be turned on or off. To enable MagicDNS,
-// there must be at least one nameserver. When all nameservers are removed,
-// MagicDNS is disabled.
-type DNSPreferences struct {
- MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
-}
-
-func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) {
- path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
- req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
- if err != nil {
- return nil, err
- }
- b, resp, err := c.sendRequest(req)
- if err != nil {
- return nil, err
- }
-
- // If status code was not successful, return the error.
- // TODO: Change the check for the StatusCode to include other 2XX success codes.
- if resp.StatusCode != http.StatusOK {
- return nil, handleErrorResponse(b, resp)
- }
-
- return b, nil
-}
-
-func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData any) ([]byte, error) {
- path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
- data, err := json.Marshal(&postData)
- if err != nil {
- return nil, err
- }
-
- req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
- req.Header.Set("Content-Type", "application/json")
- if err != nil {
- return nil, err
- }
-
- b, resp, err := c.sendRequest(req)
- if err != nil {
- return nil, err
- }
-
- // If status code was not successful, return the error.
- // TODO: Change the check for the StatusCode to include other 2XX success codes.
- if resp.StatusCode != http.StatusOK {
- return nil, handleErrorResponse(b, resp)
- }
-
- return b, nil
-}
-
-// DNSConfig retrieves the DNSConfig settings for a domain.
-func (c *Client) DNSConfig(ctx context.Context) (cfg *apitype.DNSConfig, err error) {
- // Format return errors to be descriptive.
- defer func() {
- if err != nil {
- err = fmt.Errorf("tailscale.DNSConfig: %w", err)
- }
- }()
- b, err := c.dnsGETRequest(ctx, "config")
- if err != nil {
- return nil, err
- }
- var dnsResp apitype.DNSConfig
- err = json.Unmarshal(b, &dnsResp)
- return &dnsResp, err
-}
-
-func (c *Client) SetDNSConfig(ctx context.Context, cfg apitype.DNSConfig) (resp *apitype.DNSConfig, err error) {
- // Format return errors to be descriptive.
- defer func() {
- if err != nil {
- err = fmt.Errorf("tailscale.SetDNSConfig: %w", err)
- }
- }()
- var dnsResp apitype.DNSConfig
- b, err := c.dnsPOSTRequest(ctx, "config", cfg)
- if err != nil {
- return nil, err
- }
- err = json.Unmarshal(b, &dnsResp)
- return &dnsResp, err
-}
-
-// NameServers retrieves the list of nameservers set for a domain.
-func (c *Client) NameServers(ctx context.Context) (nameservers []string, err error) {
- // Format return errors to be descriptive.
- defer func() {
- if err != nil {
- err = fmt.Errorf("tailscale.NameServers: %w", err)
- }
- }()
- b, err := c.dnsGETRequest(ctx, "nameservers")
- if err != nil {
- return nil, err
- }
- var dnsResp DNSNameServers
- err = json.Unmarshal(b, &dnsResp)
- return dnsResp.DNS, err
-}
-
-// SetNameServers sets the list of nameservers for a tailnet to the list provided
-// by the user.
-//
-// It returns the new list of nameservers and the MagicDNS status in case it was
-// affected by the change. For example, removing all nameservers will turn off
-// MagicDNS.
-func (c *Client) SetNameServers(ctx context.Context, nameservers []string) (dnsResp *DNSNameServersPostResponse, err error) {
- defer func() {
- if err != nil {
- err = fmt.Errorf("tailscale.SetNameServers: %w", err)
- }
- }()
- dnsReq := DNSNameServers{DNS: nameservers}
- b, err := c.dnsPOSTRequest(ctx, "nameservers", dnsReq)
- if err != nil {
- return nil, err
- }
- err = json.Unmarshal(b, &dnsResp)
- return dnsResp, err
-}
-
-// DNSPreferences retrieves the DNS preferences set for a tailnet.
-//
-// It returns the status of MagicDNS.
-func (c *Client) DNSPreferences(ctx context.Context) (dnsResp *DNSPreferences, err error) {
- // Format return errors to be descriptive.
- defer func() {
- if err != nil {
- err = fmt.Errorf("tailscale.DNSPreferences: %w", err)
- }
- }()
- b, err := c.dnsGETRequest(ctx, "preferences")
- if err != nil {
- return nil, err
- }
- err = json.Unmarshal(b, &dnsResp)
- return dnsResp, err
-}
-
-// SetDNSPreferences sets the DNS preferences for a tailnet.
-//
-// MagicDNS can only be enabled when there is at least one nameserver provided.
-// When all nameservers are removed, MagicDNS is disabled and will stay disabled,
-// unless explicitly enabled by a user again.
-func (c *Client) SetDNSPreferences(ctx context.Context, magicDNS bool) (dnsResp *DNSPreferences, err error) {
- defer func() {
- if err != nil {
- err = fmt.Errorf("tailscale.SetDNSPreferences: %w", err)
- }
- }()
- dnsReq := DNSPreferences{MagicDNS: magicDNS}
- b, err := c.dnsPOSTRequest(ctx, "preferences", dnsReq)
- if err != nil {
- return
- }
- err = json.Unmarshal(b, &dnsResp)
- return dnsResp, err
-}
-
-// SearchPaths retrieves the list of searchpaths set for a tailnet.
-func (c *Client) SearchPaths(ctx context.Context) (searchpaths []string, err error) {
- defer func() {
- if err != nil {
- err = fmt.Errorf("tailscale.SearchPaths: %w", err)
- }
- }()
- b, err := c.dnsGETRequest(ctx, "searchpaths")
- if err != nil {
- return nil, err
- }
- var dnsResp *DNSSearchPaths
- err = json.Unmarshal(b, &dnsResp)
- return dnsResp.SearchPaths, err
-}
-
-// SetSearchPaths sets the list of searchpaths for a tailnet.
-func (c *Client) SetSearchPaths(ctx context.Context, searchpaths []string) (newSearchPaths []string, err error) {
- defer func() {
- if err != nil {
- err = fmt.Errorf("tailscale.SetSearchPaths: %w", err)
- }
- }()
- dnsReq := DNSSearchPaths{SearchPaths: searchpaths}
- b, err := c.dnsPOSTRequest(ctx, "searchpaths", dnsReq)
- if err != nil {
- return nil, err
- }
- var dnsResp DNSSearchPaths
- err = json.Unmarshal(b, &dnsResp)
- return dnsResp.SearchPaths, err
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build go1.19
+
+package tailscale
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "tailscale.com/client/tailscale/apitype"
+)
+
+// DNSNameServers is returned when retrieving the list of nameservers.
+// It is also the structure provided when setting nameservers.
+type DNSNameServers struct {
+ DNS []string `json:"dns"` // DNS name servers
+}
+
+// DNSNameServersPostResponse is returned when setting the list of DNS nameservers.
+//
+// It includes the MagicDNS status since nameservers changes may affect MagicDNS.
+type DNSNameServersPostResponse struct {
+ DNS []string `json:"dns"` // DNS name servers
+ MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
+}
+
+// DNSSearchpaths is the list of search paths for a given domain.
+type DNSSearchPaths struct {
+ SearchPaths []string `json:"searchPaths"` // DNS search paths
+}
+
+// DNSPreferences is the preferences set for a given tailnet.
+//
+// It includes MagicDNS which can be turned on or off. To enable MagicDNS,
+// there must be at least one nameserver. When all nameservers are removed,
+// MagicDNS is disabled.
+type DNSPreferences struct {
+ MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
+}
+
+func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) {
+ path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
+ req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
+ if err != nil {
+ return nil, err
+ }
+ b, resp, err := c.sendRequest(req)
+ if err != nil {
+ return nil, err
+ }
+
+ // If status code was not successful, return the error.
+ // TODO: Change the check for the StatusCode to include other 2XX success codes.
+ if resp.StatusCode != http.StatusOK {
+ return nil, handleErrorResponse(b, resp)
+ }
+
+ return b, nil
+}
+
+func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData any) ([]byte, error) {
+ path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
+ data, err := json.Marshal(&postData)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
+ req.Header.Set("Content-Type", "application/json")
+ if err != nil {
+ return nil, err
+ }
+
+ b, resp, err := c.sendRequest(req)
+ if err != nil {
+ return nil, err
+ }
+
+ // If status code was not successful, return the error.
+ // TODO: Change the check for the StatusCode to include other 2XX success codes.
+ if resp.StatusCode != http.StatusOK {
+ return nil, handleErrorResponse(b, resp)
+ }
+
+ return b, nil
+}
+
+// DNSConfig retrieves the DNSConfig settings for a domain.
+func (c *Client) DNSConfig(ctx context.Context) (cfg *apitype.DNSConfig, err error) {
+ // Format return errors to be descriptive.
+ defer func() {
+ if err != nil {
+ err = fmt.Errorf("tailscale.DNSConfig: %w", err)
+ }
+ }()
+ b, err := c.dnsGETRequest(ctx, "config")
+ if err != nil {
+ return nil, err
+ }
+ var dnsResp apitype.DNSConfig
+ err = json.Unmarshal(b, &dnsResp)
+ return &dnsResp, err
+}
+
+func (c *Client) SetDNSConfig(ctx context.Context, cfg apitype.DNSConfig) (resp *apitype.DNSConfig, err error) {
+ // Format return errors to be descriptive.
+ defer func() {
+ if err != nil {
+ err = fmt.Errorf("tailscale.SetDNSConfig: %w", err)
+ }
+ }()
+ var dnsResp apitype.DNSConfig
+ b, err := c.dnsPOSTRequest(ctx, "config", cfg)
+ if err != nil {
+ return nil, err
+ }
+ err = json.Unmarshal(b, &dnsResp)
+ return &dnsResp, err
+}
+
+// NameServers retrieves the list of nameservers set for a domain.
+func (c *Client) NameServers(ctx context.Context) (nameservers []string, err error) {
+ // Format return errors to be descriptive.
+ defer func() {
+ if err != nil {
+ err = fmt.Errorf("tailscale.NameServers: %w", err)
+ }
+ }()
+ b, err := c.dnsGETRequest(ctx, "nameservers")
+ if err != nil {
+ return nil, err
+ }
+ var dnsResp DNSNameServers
+ err = json.Unmarshal(b, &dnsResp)
+ return dnsResp.DNS, err
+}
+
+// SetNameServers sets the list of nameservers for a tailnet to the list provided
+// by the user.
+//
+// It returns the new list of nameservers and the MagicDNS status in case it was
+// affected by the change. For example, removing all nameservers will turn off
+// MagicDNS.
+func (c *Client) SetNameServers(ctx context.Context, nameservers []string) (dnsResp *DNSNameServersPostResponse, err error) {
+ defer func() {
+ if err != nil {
+ err = fmt.Errorf("tailscale.SetNameServers: %w", err)
+ }
+ }()
+ dnsReq := DNSNameServers{DNS: nameservers}
+ b, err := c.dnsPOSTRequest(ctx, "nameservers", dnsReq)
+ if err != nil {
+ return nil, err
+ }
+ err = json.Unmarshal(b, &dnsResp)
+ return dnsResp, err
+}
+
+// DNSPreferences retrieves the DNS preferences set for a tailnet.
+//
+// It returns the status of MagicDNS.
+func (c *Client) DNSPreferences(ctx context.Context) (dnsResp *DNSPreferences, err error) {
+ // Format return errors to be descriptive.
+ defer func() {
+ if err != nil {
+ err = fmt.Errorf("tailscale.DNSPreferences: %w", err)
+ }
+ }()
+ b, err := c.dnsGETRequest(ctx, "preferences")
+ if err != nil {
+ return nil, err
+ }
+ err = json.Unmarshal(b, &dnsResp)
+ return dnsResp, err
+}
+
+// SetDNSPreferences sets the DNS preferences for a tailnet.
+//
+// MagicDNS can only be enabled when there is at least one nameserver provided.
+// When all nameservers are removed, MagicDNS is disabled and will stay disabled,
+// unless explicitly enabled by a user again.
+func (c *Client) SetDNSPreferences(ctx context.Context, magicDNS bool) (dnsResp *DNSPreferences, err error) {
+ defer func() {
+ if err != nil {
+ err = fmt.Errorf("tailscale.SetDNSPreferences: %w", err)
+ }
+ }()
+ dnsReq := DNSPreferences{MagicDNS: magicDNS}
+ b, err := c.dnsPOSTRequest(ctx, "preferences", dnsReq)
+ if err != nil {
+ return
+ }
+ err = json.Unmarshal(b, &dnsResp)
+ return dnsResp, err
+}
+
+// SearchPaths retrieves the list of searchpaths set for a tailnet.
+func (c *Client) SearchPaths(ctx context.Context) (searchpaths []string, err error) {
+ defer func() {
+ if err != nil {
+ err = fmt.Errorf("tailscale.SearchPaths: %w", err)
+ }
+ }()
+ b, err := c.dnsGETRequest(ctx, "searchpaths")
+ if err != nil {
+ return nil, err
+ }
+ var dnsResp *DNSSearchPaths
+ err = json.Unmarshal(b, &dnsResp)
+ return dnsResp.SearchPaths, err
+}
+
+// SetSearchPaths sets the list of searchpaths for a tailnet.
+func (c *Client) SetSearchPaths(ctx context.Context, searchpaths []string) (newSearchPaths []string, err error) {
+ defer func() {
+ if err != nil {
+ err = fmt.Errorf("tailscale.SetSearchPaths: %w", err)
+ }
+ }()
+ dnsReq := DNSSearchPaths{SearchPaths: searchpaths}
+ b, err := c.dnsPOSTRequest(ctx, "searchpaths", dnsReq)
+ if err != nil {
+ return nil, err
+ }
+ var dnsResp DNSSearchPaths
+ err = json.Unmarshal(b, &dnsResp)
+ return dnsResp.SearchPaths, err
+}
diff --git a/client/tailscale/example/servetls/servetls.go b/client/tailscale/example/servetls/servetls.go
index f48e90d16..e426cbea2 100644
--- a/client/tailscale/example/servetls/servetls.go
+++ b/client/tailscale/example/servetls/servetls.go
@@ -1,28 +1,28 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-// The servetls program shows how to run an HTTPS server
-// using a Tailscale cert via LetsEncrypt.
-package main
-
-import (
- "crypto/tls"
- "io"
- "log"
- "net/http"
-
- "tailscale.com/client/tailscale"
-)
-
-func main() {
- s := &http.Server{
- TLSConfig: &tls.Config{
- GetCertificate: tailscale.GetCertificate,
- },
- Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- io.WriteString(w, "<h1>Hello from Tailscale!</h1> It works.")
- }),
- }
- log.Printf("Running TLS server on :443 ...")
- log.Fatal(s.ListenAndServeTLS("", ""))
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// The servetls program shows how to run an HTTPS server
+// using a Tailscale cert via LetsEncrypt.
+package main
+
+import (
+ "crypto/tls"
+ "io"
+ "log"
+ "net/http"
+
+ "tailscale.com/client/tailscale"
+)
+
+func main() {
+ s := &http.Server{
+ TLSConfig: &tls.Config{
+ GetCertificate: tailscale.GetCertificate,
+ },
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, "<h1>Hello from Tailscale!</h1> It works.")
+ }),
+ }
+ log.Printf("Running TLS server on :443 ...")
+ log.Fatal(s.ListenAndServeTLS("", ""))
+}
diff --git a/client/tailscale/keys.go b/client/tailscale/keys.go
index 84bcdfae6..ae5f721b7 100644
--- a/client/tailscale/keys.go
+++ b/client/tailscale/keys.go
@@ -1,166 +1,166 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-package tailscale
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "time"
-)
-
-// Key represents a Tailscale API or auth key.
-type Key struct {
- ID string `json:"id"`
- Created time.Time `json:"created"`
- Expires time.Time `json:"expires"`
- Capabilities KeyCapabilities `json:"capabilities"`
-}
-
-// KeyCapabilities are the capabilities of a Key.
-type KeyCapabilities struct {
- Devices KeyDeviceCapabilities `json:"devices,omitempty"`
-}
-
-// KeyDeviceCapabilities are the device-related capabilities of a Key.
-type KeyDeviceCapabilities struct {
- Create KeyDeviceCreateCapabilities `json:"create"`
-}
-
-// KeyDeviceCreateCapabilities are the device creation capabilities of a Key.
-type KeyDeviceCreateCapabilities struct {
- Reusable bool `json:"reusable"`
- Ephemeral bool `json:"ephemeral"`
- Preauthorized bool `json:"preauthorized"`
- Tags []string `json:"tags,omitempty"`
-}
-
-// Keys returns the list of keys for the current user.
-func (c *Client) Keys(ctx context.Context) ([]string, error) {
- path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
- req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
- if err != nil {
- return nil, err
- }
-
- b, resp, err := c.sendRequest(req)
- if err != nil {
- return nil, err
- }
- if resp.StatusCode != http.StatusOK {
- return nil, handleErrorResponse(b, resp)
- }
-
- var keys struct {
- Keys []*Key `json:"keys"`
- }
- if err := json.Unmarshal(b, &keys); err != nil {
- return nil, err
- }
- ret := make([]string, 0, len(keys.Keys))
- for _, k := range keys.Keys {
- ret = append(ret, k.ID)
- }
- return ret, nil
-}
-
-// CreateKey creates a new key for the current user. Currently, only auth keys
-// can be created. It returns the secret key itself, which cannot be retrieved again
-// later, and the key metadata.
-//
-// To create a key with a specific expiry, use CreateKeyWithExpiry.
-func (c *Client) CreateKey(ctx context.Context, caps KeyCapabilities) (keySecret string, keyMeta *Key, _ error) {
- return c.CreateKeyWithExpiry(ctx, caps, 0)
-}
-
-// CreateKeyWithExpiry is like CreateKey, but allows specifying a expiration time.
-//
-// The time is truncated to a whole number of seconds. If zero, that means no expiration.
-func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities, expiry time.Duration) (keySecret string, keyMeta *Key, _ error) {
-
- // convert expirySeconds to an int64 (seconds)
- expirySeconds := int64(expiry.Seconds())
- if expirySeconds < 0 {
- return "", nil, fmt.Errorf("expiry must be positive")
- }
- if expirySeconds == 0 && expiry != 0 {
- return "", nil, fmt.Errorf("non-zero expiry must be at least one second")
- }
-
- keyRequest := struct {
- Capabilities KeyCapabilities `json:"capabilities"`
- ExpirySeconds int64 `json:"expirySeconds,omitempty"`
- }{caps, int64(expirySeconds)}
- bs, err := json.Marshal(keyRequest)
- if err != nil {
- return "", nil, err
- }
-
- path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
- req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(bs))
- if err != nil {
- return "", nil, err
- }
-
- b, resp, err := c.sendRequest(req)
- if err != nil {
- return "", nil, err
- }
- if resp.StatusCode != http.StatusOK {
- return "", nil, handleErrorResponse(b, resp)
- }
-
- var key struct {
- Key
- Secret string `json:"key"`
- }
- if err := json.Unmarshal(b, &key); err != nil {
- return "", nil, err
- }
- return key.Secret, &key.Key, nil
-}
-
-// Key returns the metadata for the given key ID. Currently, capabilities are
-// only returned for auth keys, API keys only return general metadata.
-func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
- path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
- req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
- if err != nil {
- return nil, err
- }
-
- b, resp, err := c.sendRequest(req)
- if err != nil {
- return nil, err
- }
- if resp.StatusCode != http.StatusOK {
- return nil, handleErrorResponse(b, resp)
- }
-
- var key Key
- if err := json.Unmarshal(b, &key); err != nil {
- return nil, err
- }
- return &key, nil
-}
-
-// DeleteKey deletes the key with the given ID.
-func (c *Client) DeleteKey(ctx context.Context, id string) error {
- path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
- req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
- if err != nil {
- return err
- }
-
- b, resp, err := c.sendRequest(req)
- if err != nil {
- return err
- }
- if resp.StatusCode != http.StatusOK {
- return handleErrorResponse(b, resp)
- }
- return nil
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package tailscale
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+// Key represents a Tailscale API or auth key.
+type Key struct {
+ ID string `json:"id"`
+ Created time.Time `json:"created"`
+ Expires time.Time `json:"expires"`
+ Capabilities KeyCapabilities `json:"capabilities"`
+}
+
+// KeyCapabilities are the capabilities of a Key.
+type KeyCapabilities struct {
+ Devices KeyDeviceCapabilities `json:"devices,omitempty"`
+}
+
+// KeyDeviceCapabilities are the device-related capabilities of a Key.
+type KeyDeviceCapabilities struct {
+ Create KeyDeviceCreateCapabilities `json:"create"`
+}
+
+// KeyDeviceCreateCapabilities are the device creation capabilities of a Key.
+type KeyDeviceCreateCapabilities struct {
+ Reusable bool `json:"reusable"`
+ Ephemeral bool `json:"ephemeral"`
+ Preauthorized bool `json:"preauthorized"`
+ Tags []string `json:"tags,omitempty"`
+}
+
+// Keys returns the list of keys for the current user.
+func (c *Client) Keys(ctx context.Context) ([]string, error) {
+ path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
+ req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ b, resp, err := c.sendRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, handleErrorResponse(b, resp)
+ }
+
+ var keys struct {
+ Keys []*Key `json:"keys"`
+ }
+ if err := json.Unmarshal(b, &keys); err != nil {
+ return nil, err
+ }
+ ret := make([]string, 0, len(keys.Keys))
+ for _, k := range keys.Keys {
+ ret = append(ret, k.ID)
+ }
+ return ret, nil
+}
+
+// CreateKey creates a new key for the current user. Currently, only auth keys
+// can be created. It returns the secret key itself, which cannot be retrieved again
+// later, and the key metadata.
+//
+// To create a key with a specific expiry, use CreateKeyWithExpiry.
+func (c *Client) CreateKey(ctx context.Context, caps KeyCapabilities) (keySecret string, keyMeta *Key, _ error) {
+ return c.CreateKeyWithExpiry(ctx, caps, 0)
+}
+
+// CreateKeyWithExpiry is like CreateKey, but allows specifying a expiration time.
+//
+// The time is truncated to a whole number of seconds. If zero, that means no expiration.
+func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities, expiry time.Duration) (keySecret string, keyMeta *Key, _ error) {
+
+ // convert expirySeconds to an int64 (seconds)
+ expirySeconds := int64(expiry.Seconds())
+ if expirySeconds < 0 {
+ return "", nil, fmt.Errorf("expiry must be positive")
+ }
+ if expirySeconds == 0 && expiry != 0 {
+ return "", nil, fmt.Errorf("non-zero expiry must be at least one second")
+ }
+
+ keyRequest := struct {
+ Capabilities KeyCapabilities `json:"capabilities"`
+ ExpirySeconds int64 `json:"expirySeconds,omitempty"`
+ }{caps, int64(expirySeconds)}
+ bs, err := json.Marshal(keyRequest)
+ if err != nil {
+ return "", nil, err
+ }
+
+ path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
+ req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(bs))
+ if err != nil {
+ return "", nil, err
+ }
+
+ b, resp, err := c.sendRequest(req)
+ if err != nil {
+ return "", nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return "", nil, handleErrorResponse(b, resp)
+ }
+
+ var key struct {
+ Key
+ Secret string `json:"key"`
+ }
+ if err := json.Unmarshal(b, &key); err != nil {
+ return "", nil, err
+ }
+ return key.Secret, &key.Key, nil
+}
+
+// Key returns the metadata for the given key ID. Currently, capabilities are
+// only returned for auth keys, API keys only return general metadata.
+func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
+ path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
+ req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ b, resp, err := c.sendRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, handleErrorResponse(b, resp)
+ }
+
+ var key Key
+ if err := json.Unmarshal(b, &key); err != nil {
+ return nil, err
+ }
+ return &key, nil
+}
+
+// DeleteKey deletes the key with the given ID.
+func (c *Client) DeleteKey(ctx context.Context, id string) error {
+ path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
+ req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
+ if err != nil {
+ return err
+ }
+
+ b, resp, err := c.sendRequest(req)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return handleErrorResponse(b, resp)
+ }
+ return nil
+}
diff --git a/client/tailscale/routes.go b/client/tailscale/routes.go
index 5912fc46c..41415d1b4 100644
--- a/client/tailscale/routes.go
+++ b/client/tailscale/routes.go
@@ -1,95 +1,95 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-//go:build go1.19
-
-package tailscale
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/netip"
-)
-
-// Routes contains the lists of subnet routes that are currently advertised by a device,
-// as well as the subnets that are enabled to be routed by the device.
-type Routes struct {
- AdvertisedRoutes []netip.Prefix `json:"advertisedRoutes"`
- EnabledRoutes []netip.Prefix `json:"enabledRoutes"`
-}
-
-// Routes retrieves the list of subnet routes that have been enabled for a device.
-// The routes that are returned are not necessarily advertised by the device,
-// they have only been preapproved.
-func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, err error) {
- defer func() {
- if err != nil {
- err = fmt.Errorf("tailscale.Routes: %w", err)
- }
- }()
-
- path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
- req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
- if err != nil {
- return nil, err
- }
-
- b, resp, err := c.sendRequest(req)
- if err != nil {
- return nil, err
- }
- // If status code was not successful, return the error.
- // TODO: Change the check for the StatusCode to include other 2XX success codes.
- if resp.StatusCode != http.StatusOK {
- return nil, handleErrorResponse(b, resp)
- }
-
- var sr Routes
- err = json.Unmarshal(b, &sr)
- return &sr, err
-}
-
-type postRoutesParams struct {
- Routes []netip.Prefix `json:"routes"`
-}
-
-// SetRoutes updates the list of subnets that are enabled for a device.
-// Subnets must be parsable by net/netip.ParsePrefix.
-// Subnets do not have to be currently advertised by a device, they may be pre-enabled.
-// Returns the updated list of enabled and advertised subnet routes in a *Routes object.
-func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netip.Prefix) (routes *Routes, err error) {
- defer func() {
- if err != nil {
- err = fmt.Errorf("tailscale.SetRoutes: %w", err)
- }
- }()
- params := &postRoutesParams{Routes: subnets}
- data, err := json.Marshal(params)
- if err != nil {
- return nil, err
- }
- path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
- req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
- if err != nil {
- return nil, err
- }
-
- b, resp, err := c.sendRequest(req)
- if err != nil {
- return nil, err
- }
- // If status code was not successful, return the error.
- // TODO: Change the check for the StatusCode to include other 2XX success codes.
- if resp.StatusCode != http.StatusOK {
- return nil, handleErrorResponse(b, resp)
- }
-
- var srr *Routes
- if err := json.Unmarshal(b, &srr); err != nil {
- return nil, err
- }
- return srr, err
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build go1.19
+
+package tailscale
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/netip"
+)
+
+// Routes contains the lists of subnet routes that are currently advertised by a device,
+// as well as the subnets that are enabled to be routed by the device.
+type Routes struct {
+ AdvertisedRoutes []netip.Prefix `json:"advertisedRoutes"`
+ EnabledRoutes []netip.Prefix `json:"enabledRoutes"`
+}
+
+// Routes retrieves the list of subnet routes that have been enabled for a device.
+// The routes that are returned are not necessarily advertised by the device,
+// they have only been preapproved.
+func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, err error) {
+ defer func() {
+ if err != nil {
+ err = fmt.Errorf("tailscale.Routes: %w", err)
+ }
+ }()
+
+ path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
+ req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ b, resp, err := c.sendRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ // If status code was not successful, return the error.
+ // TODO: Change the check for the StatusCode to include other 2XX success codes.
+ if resp.StatusCode != http.StatusOK {
+ return nil, handleErrorResponse(b, resp)
+ }
+
+ var sr Routes
+ err = json.Unmarshal(b, &sr)
+ return &sr, err
+}
+
+type postRoutesParams struct {
+ Routes []netip.Prefix `json:"routes"`
+}
+
+// SetRoutes updates the list of subnets that are enabled for a device.
+// Subnets must be parsable by net/netip.ParsePrefix.
+// Subnets do not have to be currently advertised by a device, they may be pre-enabled.
+// Returns the updated list of enabled and advertised subnet routes in a *Routes object.
+func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netip.Prefix) (routes *Routes, err error) {
+ defer func() {
+ if err != nil {
+ err = fmt.Errorf("tailscale.SetRoutes: %w", err)
+ }
+ }()
+ params := &postRoutesParams{Routes: subnets}
+ data, err := json.Marshal(params)
+ if err != nil {
+ return nil, err
+ }
+ path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
+ req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
+ if err != nil {
+ return nil, err
+ }
+
+ b, resp, err := c.sendRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ // If status code was not successful, return the error.
+ // TODO: Change the check for the StatusCode to include other 2XX success codes.
+ if resp.StatusCode != http.StatusOK {
+ return nil, handleErrorResponse(b, resp)
+ }
+
+ var srr *Routes
+ if err := json.Unmarshal(b, &srr); err != nil {
+ return nil, err
+ }
+ return srr, err
+}
diff --git a/client/tailscale/tailnet.go b/client/tailscale/tailnet.go
index 2539e7f23..eef2dca20 100644
--- a/client/tailscale/tailnet.go
+++ b/client/tailscale/tailnet.go
@@ -1,42 +1,42 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-//go:build go1.19
-
-package tailscale
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/url"
-
- "tailscale.com/util/httpm"
-)
-
-// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control.
-func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (err error) {
- defer func() {
- if err != nil {
- err = fmt.Errorf("tailscale.DeleteTailnet: %w", err)
- }
- }()
-
- path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
- req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
- if err != nil {
- return err
- }
-
- c.setAuth(req)
- b, resp, err := c.sendRequest(req)
- if err != nil {
- return err
- }
-
- if resp.StatusCode != http.StatusOK {
- return handleErrorResponse(b, resp)
- }
-
- return nil
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build go1.19
+
+package tailscale
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "tailscale.com/util/httpm"
+)
+
+// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control.
+func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (err error) {
+ defer func() {
+ if err != nil {
+ err = fmt.Errorf("tailscale.DeleteTailnet: %w", err)
+ }
+ }()
+
+ path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
+ req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
+ if err != nil {
+ return err
+ }
+
+ c.setAuth(req)
+ b, resp, err := c.sendRequest(req)
+ if err != nil {
+ return err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return handleErrorResponse(b, resp)
+ }
+
+ return nil
+}
diff --git a/client/web/qnap.go b/client/web/qnap.go
index 9bde64bf5..8fa5ee174 100644
--- a/client/web/qnap.go
+++ b/client/web/qnap.go
@@ -1,127 +1,127 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-// qnap.go contains handlers and logic, such as authentication,
-// that is specific to running the web client on QNAP.
-
-package web
-
-import (
- "crypto/tls"
- "encoding/xml"
- "errors"
- "fmt"
- "io"
- "log"
- "net/http"
- "net/url"
-)
-
-// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
-// are authorized to use the web client.
-// If the user is not authorized to use the client, an error is returned.
-func authorizeQNAP(r *http.Request) (authorized bool, err error) {
- _, resp, err := qnapAuthn(r)
- if err != nil {
- return false, err
- }
- if resp.IsAdmin == 0 {
- return false, errors.New("user is not an admin")
- }
-
- return true, nil
-}
-
-type qnapAuthResponse struct {
- AuthPassed int `xml:"authPassed"`
- IsAdmin int `xml:"isAdmin"`
- AuthSID string `xml:"authSid"`
- ErrorValue int `xml:"errorValue"`
-}
-
-func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
- user, err := r.Cookie("NAS_USER")
- if err != nil {
- return "", nil, err
- }
- token, err := r.Cookie("qtoken")
- if err == nil {
- return qnapAuthnQtoken(r, user.Value, token.Value)
- }
- sid, err := r.Cookie("NAS_SID")
- if err == nil {
- return qnapAuthnSid(r, user.Value, sid.Value)
- }
- return "", nil, fmt.Errorf("not authenticated by any mechanism")
-}
-
-// qnapAuthnURL returns the auth URL to use by inferring where the UI is
-// running based on the request URL. This is necessary because QNAP has so
-// many options, see https://github.com/tailscale/tailscale/issues/7108
-// and https://github.com/tailscale/tailscale/issues/6903
-func qnapAuthnURL(requestUrl string, query url.Values) string {
- in, err := url.Parse(requestUrl)
- scheme := ""
- host := ""
- if err != nil || in.Scheme == "" {
- log.Printf("Cannot parse QNAP login URL %v", err)
-
- // try localhost and hope for the best
- scheme = "http"
- host = "localhost"
- } else {
- scheme = in.Scheme
- host = in.Host
- }
-
- u := url.URL{
- Scheme: scheme,
- Host: host,
- Path: "/cgi-bin/authLogin.cgi",
- RawQuery: query.Encode(),
- }
-
- return u.String()
-}
-
-func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
- query := url.Values{
- "qtoken": []string{token},
- "user": []string{user},
- }
- return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
-}
-
-func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
- query := url.Values{
- "sid": []string{sid},
- }
- return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
-}
-
-func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
- // QNAP Force HTTPS mode uses a self-signed certificate. Even importing
- // the QNAP root CA isn't enough, the cert doesn't have a usable CN nor
- // SAN. See https://github.com/tailscale/tailscale/issues/6903
- tr := &http.Transport{
- TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
- }
- client := &http.Client{Transport: tr}
- resp, err := client.Get(url)
- if err != nil {
- return "", nil, err
- }
- defer resp.Body.Close()
- out, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", nil, err
- }
- authResp := &qnapAuthResponse{}
- if err := xml.Unmarshal(out, authResp); err != nil {
- return "", nil, err
- }
- if authResp.AuthPassed == 0 {
- return "", nil, fmt.Errorf("not authenticated")
- }
- return user, authResp, nil
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// qnap.go contains handlers and logic, such as authentication,
+// that is specific to running the web client on QNAP.
+
+package web
+
+import (
+ "crypto/tls"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+)
+
+// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
+// are authorized to use the web client.
+// If the user is not authorized to use the client, an error is returned.
+func authorizeQNAP(r *http.Request) (authorized bool, err error) {
+ _, resp, err := qnapAuthn(r)
+ if err != nil {
+ return false, err
+ }
+ if resp.IsAdmin == 0 {
+ return false, errors.New("user is not an admin")
+ }
+
+ return true, nil
+}
+
+type qnapAuthResponse struct {
+ AuthPassed int `xml:"authPassed"`
+ IsAdmin int `xml:"isAdmin"`
+ AuthSID string `xml:"authSid"`
+ ErrorValue int `xml:"errorValue"`
+}
+
+func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
+ user, err := r.Cookie("NAS_USER")
+ if err != nil {
+ return "", nil, err
+ }
+ token, err := r.Cookie("qtoken")
+ if err == nil {
+ return qnapAuthnQtoken(r, user.Value, token.Value)
+ }
+ sid, err := r.Cookie("NAS_SID")
+ if err == nil {
+ return qnapAuthnSid(r, user.Value, sid.Value)
+ }
+ return "", nil, fmt.Errorf("not authenticated by any mechanism")
+}
+
+// qnapAuthnURL returns the auth URL to use by inferring where the UI is
+// running based on the request URL. This is necessary because QNAP has so
+// many options, see https://github.com/tailscale/tailscale/issues/7108
+// and https://github.com/tailscale/tailscale/issues/6903
+func qnapAuthnURL(requestUrl string, query url.Values) string {
+ in, err := url.Parse(requestUrl)
+ scheme := ""
+ host := ""
+ if err != nil || in.Scheme == "" {
+ log.Printf("Cannot parse QNAP login URL %v", err)
+
+ // try localhost and hope for the best
+ scheme = "http"
+ host = "localhost"
+ } else {
+ scheme = in.Scheme
+ host = in.Host
+ }
+
+ u := url.URL{
+ Scheme: scheme,
+ Host: host,
+ Path: "/cgi-bin/authLogin.cgi",
+ RawQuery: query.Encode(),
+ }
+
+ return u.String()
+}
+
+func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
+ query := url.Values{
+ "qtoken": []string{token},
+ "user": []string{user},
+ }
+ return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
+}
+
+func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
+ query := url.Values{
+ "sid": []string{sid},
+ }
+ return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
+}
+
+func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
+ // QNAP Force HTTPS mode uses a self-signed certificate. Even importing
+ // the QNAP root CA isn't enough, the cert doesn't have a usable CN nor
+ // SAN. See https://github.com/tailscale/tailscale/issues/6903
+ tr := &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ }
+ client := &http.Client{Transport: tr}
+ resp, err := client.Get(url)
+ if err != nil {
+ return "", nil, err
+ }
+ defer resp.Body.Close()
+ out, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", nil, err
+ }
+ authResp := &qnapAuthResponse{}
+ if err := xml.Unmarshal(out, authResp); err != nil {
+ return "", nil, err
+ }
+ if authResp.AuthPassed == 0 {
+ return "", nil, fmt.Errorf("not authenticated")
+ }
+ return user, authResp, nil
+}
diff --git a/client/web/src/assets/icons/arrow-right.svg b/client/web/src/assets/icons/arrow-right.svg
index fbc4bb7ae..0a32ef484 100644
--- a/client/web/src/assets/icons/arrow-right.svg
+++ b/client/web/src/assets/icons/arrow-right.svg
@@ -1,4 +1,4 @@
-<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5 12.5H19" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M12 5.5L19 12.5L12 19.5" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5 12.5H19" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12 5.5L19 12.5L12 19.5" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
diff --git a/client/web/src/assets/icons/arrow-up-circle.svg b/client/web/src/assets/icons/arrow-up-circle.svg
index e9d009eb6..e64c836be 100644
--- a/client/web/src/assets/icons/arrow-up-circle.svg
+++ b/client/web/src/assets/icons/arrow-up-circle.svg
@@ -1,5 +1,5 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M16 12L12 8L8 12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M12 16V8" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M16 12L12 8L8 12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M12 16V8" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
diff --git a/client/web/src/assets/icons/check-circle.svg b/client/web/src/assets/icons/check-circle.svg
index 4daeed514..6c5ee519e 100644
--- a/client/web/src/assets/icons/check-circle.svg
+++ b/client/web/src/assets/icons/check-circle.svg
@@ -1,4 +1,4 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M22 4L12 14.01L9 11.01" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M22 4L12 14.01L9 11.01" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
diff --git a/client/web/src/assets/icons/check.svg b/client/web/src/assets/icons/check.svg
index efa11685d..70027536a 100644
--- a/client/web/src/assets/icons/check.svg
+++ b/client/web/src/assets/icons/check.svg
@@ -1,3 +1,3 @@
-<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M16.6673 5L7.50065 14.1667L3.33398 10" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M16.6673 5L7.50065 14.1667L3.33398 10" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
diff --git a/client/web/src/assets/icons/chevron-down.svg b/client/web/src/assets/icons/chevron-down.svg
index afc98f255..993744c2f 100644
--- a/client/web/src/assets/icons/chevron-down.svg
+++ b/client/web/src/assets/icons/chevron-down.svg
@@ -1,3 +1,3 @@
-<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5 7.5L10 12.5L15 7.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5 7.5L10 12.5L15 7.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
diff --git a/client/web/src/assets/icons/eye.svg b/client/web/src/assets/icons/eye.svg
index b0b21ed3f..e27767477 100644
--- a/client/web/src/assets/icons/eye.svg
+++ b/client/web/src/assets/icons/eye.svg
@@ -1,11 +1,11 @@
-<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_15367_14595)">
-<path d="M0.625 8C0.625 8 3.125 3 7.5 3C11.875 3 14.375 8 14.375 8C14.375 8 11.875 13 7.5 13C3.125 13 0.625 8 0.625 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7.5 9.875C8.53553 9.875 9.375 9.03553 9.375 8C9.375 6.96447 8.53553 6.125 7.5 6.125C6.46447 6.125 5.625 6.96447 5.625 8C5.625 9.03553 6.46447 9.875 7.5 9.875Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</g>
-<defs>
-<clipPath id="clip0_15367_14595">
-<rect width="15" height="15" fill="white" transform="translate(0 0.5)"/>
-</clipPath>
-</defs>
-</svg>
+<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_15367_14595)">
+<path d="M0.625 8C0.625 8 3.125 3 7.5 3C11.875 3 14.375 8 14.375 8C14.375 8 11.875 13 7.5 13C3.125 13 0.625 8 0.625 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.5 9.875C8.53553 9.875 9.375 9.03553 9.375 8C9.375 6.96447 8.53553 6.125 7.5 6.125C6.46447 6.125 5.625 6.96447 5.625 8C5.625 9.03553 6.46447 9.875 7.5 9.875Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<defs>
+<clipPath id="clip0_15367_14595">
+<rect width="15" height="15" fill="white" transform="translate(0 0.5)"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/client/web/src/assets/icons/search.svg b/client/web/src/assets/icons/search.svg
index 782cd90ee..08eb2d3dc 100644
--- a/client/web/src/assets/icons/search.svg
+++ b/client/web/src/assets/icons/search.svg
@@ -1,4 +1,4 @@
-<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M17.5 17.5L13.875 13.875" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M17.5 17.5L13.875 13.875" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
diff --git a/client/web/src/assets/icons/tailscale-icon.svg b/client/web/src/assets/icons/tailscale-icon.svg
index d6052fe5e..de3c975ce 100644
--- a/client/web/src/assets/icons/tailscale-icon.svg
+++ b/client/web/src/assets/icons/tailscale-icon.svg
@@ -1,18 +1,18 @@
-<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_13627_11860)">
-<path opacity="0.2" d="M3.8696 6.77137C5.56662 6.77137 6.94233 5.39567 6.94233 3.69865C6.94233 2.00163 5.56662 0.625919 3.8696 0.625919C2.17258 0.625919 0.796875 2.00163 0.796875 3.69865C0.796875 5.39567 2.17258 6.77137 3.8696 6.77137Z" fill="black"/>
-<path d="M3.8696 15.9327C5.56662 15.9327 6.94233 14.5569 6.94233 12.8599C6.94233 11.1629 5.56662 9.7872 3.8696 9.7872C2.17258 9.7872 0.796875 11.1629 0.796875 12.8599C0.796875 14.5569 2.17258 15.9327 3.8696 15.9327Z" fill="black"/>
-<path opacity="0.2" d="M3.8696 25.2646C5.56662 25.2646 6.94233 23.8889 6.94233 22.1919C6.94233 20.4949 5.56662 19.1192 3.8696 19.1192C2.17258 19.1192 0.796875 20.4949 0.796875 22.1919C0.796875 23.8889 2.17258 25.2646 3.8696 25.2646Z" fill="black"/>
-<path d="M13.0879 15.9327C14.7849 15.9327 16.1606 14.5569 16.1606 12.8599C16.1606 11.1629 14.7849 9.7872 13.0879 9.7872C11.3908 9.7872 10.0151 11.1629 10.0151 12.8599C10.0151 14.5569 11.3908 15.9327 13.0879 15.9327Z" fill="black"/>
-<path d="M13.0879 25.2646C14.7849 25.2646 16.1606 23.8889 16.1606 22.1919C16.1606 20.4949 14.7849 19.1192 13.0879 19.1192C11.3908 19.1192 10.0151 20.4949 10.0151 22.1919C10.0151 23.8889 11.3908 25.2646 13.0879 25.2646Z" fill="black"/>
-<path opacity="0.2" d="M13.0879 6.77137C14.7849 6.77137 16.1606 5.39567 16.1606 3.69865C16.1606 2.00163 14.7849 0.625919 13.0879 0.625919C11.3908 0.625919 10.0151 2.00163 10.0151 3.69865C10.0151 5.39567 11.3908 6.77137 13.0879 6.77137Z" fill="black"/>
-<path opacity="0.2" d="M22.1919 6.77137C23.8889 6.77137 25.2646 5.39567 25.2646 3.69865C25.2646 2.00163 23.8889 0.625919 22.1919 0.625919C20.4948 0.625919 19.1191 2.00163 19.1191 3.69865C19.1191 5.39567 20.4948 6.77137 22.1919 6.77137Z" fill="black"/>
-<path d="M22.1919 15.9327C23.8889 15.9327 25.2646 14.5569 25.2646 12.8599C25.2646 11.1629 23.8889 9.7872 22.1919 9.7872C20.4948 9.7872 19.1191 11.1629 19.1191 12.8599C19.1191 14.5569 20.4948 15.9327 22.1919 15.9327Z" fill="black"/>
-<path opacity="0.2" d="M22.1919 25.2646C23.8889 25.2646 25.2646 23.8889 25.2646 22.1919C25.2646 20.4949 23.8889 19.1192 22.1919 19.1192C20.4948 19.1192 19.1191 20.4949 19.1191 22.1919C19.1191 23.8889 20.4948 25.2646 22.1919 25.2646Z" fill="black"/>
-</g>
-<defs>
-<clipPath id="clip0_13627_11860">
-<rect width="26" height="26" fill="white"/>
-</clipPath>
-</defs>
-</svg>
+<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_13627_11860)">
+<path opacity="0.2" d="M3.8696 6.77137C5.56662 6.77137 6.94233 5.39567 6.94233 3.69865C6.94233 2.00163 5.56662 0.625919 3.8696 0.625919C2.17258 0.625919 0.796875 2.00163 0.796875 3.69865C0.796875 5.39567 2.17258 6.77137 3.8696 6.77137Z" fill="black"/>
+<path d="M3.8696 15.9327C5.56662 15.9327 6.94233 14.5569 6.94233 12.8599C6.94233 11.1629 5.56662 9.7872 3.8696 9.7872C2.17258 9.7872 0.796875 11.1629 0.796875 12.8599C0.796875 14.5569 2.17258 15.9327 3.8696 15.9327Z" fill="black"/>
+<path opacity="0.2" d="M3.8696 25.2646C5.56662 25.2646 6.94233 23.8889 6.94233 22.1919C6.94233 20.4949 5.56662 19.1192 3.8696 19.1192C2.17258 19.1192 0.796875 20.4949 0.796875 22.1919C0.796875 23.8889 2.17258 25.2646 3.8696 25.2646Z" fill="black"/>
+<path d="M13.0879 15.9327C14.7849 15.9327 16.1606 14.5569 16.1606 12.8599C16.1606 11.1629 14.7849 9.7872 13.0879 9.7872C11.3908 9.7872 10.0151 11.1629 10.0151 12.8599C10.0151 14.5569 11.3908 15.9327 13.0879 15.9327Z" fill="black"/>
+<path d="M13.0879 25.2646C14.7849 25.2646 16.1606 23.8889 16.1606 22.1919C16.1606 20.4949 14.7849 19.1192 13.0879 19.1192C11.3908 19.1192 10.0151 20.4949 10.0151 22.1919C10.0151 23.8889 11.3908 25.2646 13.0879 25.2646Z" fill="black"/>
+<path opacity="0.2" d="M13.0879 6.77137C14.7849 6.77137 16.1606 5.39567 16.1606 3.69865C16.1606 2.00163 14.7849 0.625919 13.0879 0.625919C11.3908 0.625919 10.0151 2.00163 10.0151 3.69865C10.0151 5.39567 11.3908 6.77137 13.0879 6.77137Z" fill="black"/>
+<path opacity="0.2" d="M22.1919 6.77137C23.8889 6.77137 25.2646 5.39567 25.2646 3.69865C25.2646 2.00163 23.8889 0.625919 22.1919 0.625919C20.4948 0.625919 19.1191 2.00163 19.1191 3.69865C19.1191 5.39567 20.4948 6.77137 22.1919 6.77137Z" fill="black"/>
+<path d="M22.1919 15.9327C23.8889 15.9327 25.2646 14.5569 25.2646 12.8599C25.2646 11.1629 23.8889 9.7872 22.1919 9.7872C20.4948 9.7872 19.1191 11.1629 19.1191 12.8599C19.1191 14.5569 20.4948 15.9327 22.1919 15.9327Z" fill="black"/>
+<path opacity="0.2" d="M22.1919 25.2646C23.8889 25.2646 25.2646 23.8889 25.2646 22.1919C25.2646 20.4949 23.8889 19.1192 22.1919 19.1192C20.4948 19.1192 19.1191 20.4949 19.1191 22.1919C19.1191 23.8889 20.4948 25.2646 22.1919 25.2646Z" fill="black"/>
+</g>
+<defs>
+<clipPath id="clip0_13627_11860">
+<rect width="26" height="26" fill="white"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/client/web/src/assets/icons/tailscale-logo.svg b/client/web/src/assets/icons/tailscale-logo.svg
index 6d5c7ce0c..94a9cc4ee 100644
--- a/client/web/src/assets/icons/tailscale-logo.svg
+++ b/client/web/src/assets/icons/tailscale-logo.svg
@@ -1,20 +1,20 @@
-<svg width="121" height="22" viewBox="0 0 121 22" fill="none" xmlns="http://www.w3.org/2000/svg">
-<ellipse cx="2.69191" cy="10.7677" rx="2.69191" ry="2.69191" fill="#141414"/>
-<ellipse cx="10.7676" cy="10.7677" rx="2.69191" ry="2.69191" fill="#141414"/>
-<ellipse opacity="0.2" cx="2.69191" cy="18.8434" rx="2.69191" ry="2.69191" fill="#141414"/>
-<circle opacity="0.2" cx="18.8433" cy="18.8434" r="2.69191" fill="#141414"/>
-<ellipse cx="10.7676" cy="18.8434" rx="2.69191" ry="2.69191" fill="#141414"/>
-<circle cx="18.8433" cy="10.7677" r="2.69191" fill="#141414"/>
-<ellipse opacity="0.2" cx="2.69191" cy="2.69191" rx="2.69191" ry="2.69191" fill="#141414"/>
-<ellipse opacity="0.2" cx="10.7676" cy="2.69191" rx="2.69191" ry="2.69191" fill="#141414"/>
-<circle opacity="0.2" cx="18.8433" cy="2.69191" r="2.69191" fill="#141414"/>
-<path d="M37.8847 19.9603C38.6525 19.9603 39.2764 19.8883 40.0202 19.7443V16.9609C39.5643 17.1289 39.0605 17.1769 38.5806 17.1769C37.4048 17.1769 36.9729 16.601 36.9729 15.4973V9.83453H40.0202V7.05116H36.9729V2.92409H33.6137V7.05116H31.4302V9.83453H33.6137V15.8092C33.6137 18.4486 35.0054 19.9603 37.8847 19.9603Z" fill="#141414"/>
-<path d="M45.5064 19.9603C47.306 19.9603 48.5057 19.3604 49.1056 18.4246C49.1536 18.8325 49.2975 19.3844 49.4895 19.7203H52.5128C52.3448 19.1444 52.2249 18.2326 52.2249 17.6328V11.0583C52.2249 8.34687 50.2813 6.81121 46.994 6.81121C44.4986 6.81121 42.555 7.747 41.4753 9.1147L43.3949 11.0103C44.2587 10.0505 45.3624 9.5466 46.7061 9.5466C48.3377 9.5466 49.0576 10.0985 49.0576 10.9143C49.0576 11.6101 48.5777 12.09 45.9863 12.09C43.4908 12.09 40.9714 13.1218 40.9714 16.0011C40.9714 18.6645 42.891 19.9603 45.5064 19.9603ZM46.1782 17.4168C44.8825 17.4168 44.2827 16.8649 44.2827 15.8812C44.2827 15.0174 45.0025 14.4415 46.2022 14.4415C48.1218 14.4415 48.6497 14.3215 49.0576 13.9136V14.9454C49.0576 16.3131 47.9058 17.4168 46.1782 17.4168Z" fill="#141414"/>
-<path d="M54.4086 5.44352H57.9118V2.30023H54.4086V5.44352ZM54.4805 19.7203H57.8398V7.05116H54.4805V19.7203Z" fill="#141414"/>
-<path d="M60.287 19.7203H63.6463V2.68414H60.287V19.7203Z" fill="#141414"/>
-<path d="M70.6285 19.9603C74.3237 19.9603 76.2193 18.0167 76.2193 15.9771C76.2193 14.1296 75.2835 12.7619 72.2122 12.21C70.0527 11.8261 68.709 11.3462 68.709 10.6024C68.709 9.95451 69.4768 9.49861 70.7725 9.49861C71.9242 9.49861 72.884 9.88252 73.6038 10.7223L75.7394 8.92274C74.6596 7.57904 72.884 6.81121 70.7725 6.81121C67.5332 6.81121 65.5177 8.53883 65.5177 10.6503C65.5177 12.9538 67.6292 13.9856 69.9087 14.3935C71.8043 14.7294 72.86 15.0893 72.86 15.9052C72.86 16.601 72.1162 17.1769 70.7005 17.1769C69.3088 17.1769 68.2291 16.529 67.7252 15.5692L64.8938 16.9129C65.5897 18.6405 67.9651 19.9603 70.6285 19.9603Z" fill="#141414"/>
-<path d="M83.7294 19.9603C86.1288 19.9603 87.8564 19.0005 89.1521 16.841L86.4648 15.4733C85.9609 16.481 85.1451 17.1769 83.7294 17.1769C81.5939 17.1769 80.4421 15.4493 80.4421 13.3617C80.4421 11.2742 81.6658 9.59459 83.7294 9.59459C85.0251 9.59459 85.8889 10.2904 86.3928 11.3462L89.1042 9.90652C88.1924 7.91497 86.3928 6.81121 83.7294 6.81121C79.3384 6.81121 77.0829 10.0265 77.0829 13.3617C77.0829 16.9849 79.8183 19.9603 83.7294 19.9603Z" fill="#141414"/>
-<path d="M94.5031 19.9603C96.3027 19.9603 97.5025 19.3604 98.1023 18.4246C98.1503 18.8325 98.2943 19.3844 98.4862 19.7203H101.51C101.342 19.1444 101.222 18.2326 101.222 17.6328V11.0583C101.222 8.34687 99.2781 6.81121 95.9908 6.81121C93.4954 6.81121 91.5518 7.747 90.472 9.1147L92.3916 11.0103C93.2554 10.0505 94.3592 9.5466 95.7029 9.5466C97.3345 9.5466 98.0543 10.0985 98.0543 10.9143C98.0543 11.6101 97.5744 12.09 94.983 12.09C92.4876 12.09 89.9682 13.1218 89.9682 16.0011C89.9682 18.6645 91.8877 19.9603 94.5031 19.9603ZM95.175 17.4168C93.8793 17.4168 93.2794 16.8649 93.2794 15.8812C93.2794 15.0174 93.9992 14.4415 95.199 14.4415C97.1185 14.4415 97.6464 14.3215 98.0543 13.9136V14.9454C98.0543 16.3131 96.9026 17.4168 95.175 17.4168Z" fill="#141414"/>
-<path d="M103.196 19.7203H106.555V2.68414H103.196V19.7203Z" fill="#141414"/>
-<path d="M114.617 19.9603C117.089 19.9603 119.08 18.9765 120.184 17.2249L117.641 15.5932C116.969 16.649 116.081 17.2249 114.617 17.2249C112.962 17.2249 111.762 16.3131 111.45 14.5375H121V13.3617C121 10.0265 118.96 6.81121 114.593 6.81121C110.442 6.81121 108.187 10.0505 108.187 13.3857C108.187 18.1367 111.762 19.9603 114.617 19.9603ZM111.57 11.8981C112.098 10.2904 113.202 9.5466 114.665 9.5466C116.321 9.5466 117.329 10.5304 117.665 11.8981H111.57Z" fill="#141414"/>
-</svg>
+<svg width="121" height="22" viewBox="0 0 121 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+<ellipse cx="2.69191" cy="10.7677" rx="2.69191" ry="2.69191" fill="#141414"/>
+<ellipse cx="10.7676" cy="10.7677" rx="2.69191" ry="2.69191" fill="#141414"/>
+<ellipse opacity="0.2" cx="2.69191" cy="18.8434" rx="2.69191" ry="2.69191" fill="#141414"/>
+<circle opacity="0.2" cx="18.8433" cy="18.8434" r="2.69191" fill="#141414"/>
+<ellipse cx="10.7676" cy="18.8434" rx="2.69191" ry="2.69191" fill="#141414"/>
+<circle cx="18.8433" cy="10.7677" r="2.69191" fill="#141414"/>
+<ellipse opacity="0.2" cx="2.69191" cy="2.69191" rx="2.69191" ry="2.69191" fill="#141414"/>
+<ellipse opacity="0.2" cx="10.7676" cy="2.69191" rx="2.69191" ry="2.69191" fill="#141414"/>
+<circle opacity="0.2" cx="18.8433" cy="2.69191" r="2.69191" fill="#141414"/>
+<path d="M37.8847 19.9603C38.6525 19.9603 39.2764 19.8883 40.0202 19.7443V16.9609C39.5643 17.1289 39.0605 17.1769 38.5806 17.1769C37.4048 17.1769 36.9729 16.601 36.9729 15.4973V9.83453H40.0202V7.05116H36.9729V2.92409H33.6137V7.05116H31.4302V9.83453H33.6137V15.8092C33.6137 18.4486 35.0054 19.9603 37.8847 19.9603Z" fill="#141414"/>
+<path d="M45.5064 19.9603C47.306 19.9603 48.5057 19.3604 49.1056 18.4246C49.1536 18.8325 49.2975 19.3844 49.4895 19.7203H52.5128C52.3448 19.1444 52.2249 18.2326 52.2249 17.6328V11.0583C52.2249 8.34687 50.2813 6.81121 46.994 6.81121C44.4986 6.81121 42.555 7.747 41.4753 9.1147L43.3949 11.0103C44.2587 10.0505 45.3624 9.5466 46.7061 9.5466C48.3377 9.5466 49.0576 10.0985 49.0576 10.9143C49.0576 11.6101 48.5777 12.09 45.9863 12.09C43.4908 12.09 40.9714 13.1218 40.9714 16.0011C40.9714 18.6645 42.891 19.9603 45.5064 19.9603ZM46.1782 17.4168C44.8825 17.4168 44.2827 16.8649 44.2827 15.8812C44.2827 15.0174 45.0025 14.4415 46.2022 14.4415C48.1218 14.4415 48.6497 14.3215 49.0576 13.9136V14.9454C49.0576 16.3131 47.9058 17.4168 46.1782 17.4168Z" fill="#141414"/>
+<path d="M54.4086 5.44352H57.9118V2.30023H54.4086V5.44352ZM54.4805 19.7203H57.8398V7.05116H54.4805V19.7203Z" fill="#141414"/>
+<path d="M60.287 19.7203H63.6463V2.68414H60.287V19.7203Z" fill="#141414"/>
+<path d="M70.6285 19.9603C74.3237 19.9603 76.2193 18.0167 76.2193 15.9771C76.2193 14.1296 75.2835 12.7619 72.2122 12.21C70.0527 11.8261 68.709 11.3462 68.709 10.6024C68.709 9.95451 69.4768 9.49861 70.7725 9.49861C71.9242 9.49861 72.884 9.88252 73.6038 10.7223L75.7394 8.92274C74.6596 7.57904 72.884 6.81121 70.7725 6.81121C67.5332 6.81121 65.5177 8.53883 65.5177 10.6503C65.5177 12.9538 67.6292 13.9856 69.9087 14.3935C71.8043 14.7294 72.86 15.0893 72.86 15.9052C72.86 16.601 72.1162 17.1769 70.7005 17.1769C69.3088 17.1769 68.2291 16.529 67.7252 15.5692L64.8938 16.9129C65.5897 18.6405 67.9651 19.9603 70.6285 19.9603Z" fill="#141414"/>
+<path d="M83.7294 19.9603C86.1288 19.9603 87.8564 19.0005 89.1521 16.841L86.4648 15.4733C85.9609 16.481 85.1451 17.1769 83.7294 17.1769C81.5939 17.1769 80.4421 15.4493 80.4421 13.3617C80.4421 11.2742 81.6658 9.59459 83.7294 9.59459C85.0251 9.59459 85.8889 10.2904 86.3928 11.3462L89.1042 9.90652C88.1924 7.91497 86.3928 6.81121 83.7294 6.81121C79.3384 6.81121 77.0829 10.0265 77.0829 13.3617C77.0829 16.9849 79.8183 19.9603 83.7294 19.9603Z" fill="#141414"/>
+<path d="M94.5031 19.9603C96.3027 19.9603 97.5025 19.3604 98.1023 18.4246C98.1503 18.8325 98.2943 19.3844 98.4862 19.7203H101.51C101.342 19.1444 101.222 18.2326 101.222 17.6328V11.0583C101.222 8.34687 99.2781 6.81121 95.9908 6.81121C93.4954 6.81121 91.5518 7.747 90.472 9.1147L92.3916 11.0103C93.2554 10.0505 94.3592 9.5466 95.7029 9.5466C97.3345 9.5466 98.0543 10.0985 98.0543 10.9143C98.0543 11.6101 97.5744 12.09 94.983 12.09C92.4876 12.09 89.9682 13.1218 89.9682 16.0011C89.9682 18.6645 91.8877 19.9603 94.5031 19.9603ZM95.175 17.4168C93.8793 17.4168 93.2794 16.8649 93.2794 15.8812C93.2794 15.0174 93.9992 14.4415 95.199 14.4415C97.1185 14.4415 97.6464 14.3215 98.0543 13.9136V14.9454C98.0543 16.3131 96.9026 17.4168 95.175 17.4168Z" fill="#141414"/>
+<path d="M103.196 19.7203H106.555V2.68414H103.196V19.7203Z" fill="#141414"/>
+<path d="M114.617 19.9603C117.089 19.9603 119.08 18.9765 120.184 17.2249L117.641 15.5932C116.969 16.649 116.081 17.2249 114.617 17.2249C112.962 17.2249 111.762 16.3131 111.45 14.5375H121V13.3617C121 10.0265 118.96 6.81121 114.593 6.81121C110.442 6.81121 108.187 10.0505 108.187 13.3857C108.187 18.1367 111.762 19.9603 114.617 19.9603ZM111.57 11.8981C112.098 10.2904 113.202 9.5466 114.665 9.5466C116.321 9.5466 117.329 10.5304 117.665 11.8981H111.57Z" fill="#141414"/>
+</svg>
diff --git a/client/web/src/assets/icons/user.svg b/client/web/src/assets/icons/user.svg
index 29d86f049..7fa3d2603 100644
--- a/client/web/src/assets/icons/user.svg
+++ b/client/web/src/assets/icons/user.svg
@@ -1,4 +1,4 @@
-<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12.5 13.625V12.375C12.5 11.712 12.2366 11.0761 11.7678 10.6072C11.2989 10.1384 10.663 9.875 10 9.875H5C4.33696 9.875 3.70107 10.1384 3.23223 10.6072C2.76339 11.0761 2.5 11.712 2.5 12.375V13.625" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7.5 7.375C8.88071 7.375 10 6.25571 10 4.875C10 3.49429 8.88071 2.375 7.5 2.375C6.11929 2.375 5 3.49429 5 4.875C5 6.25571 6.11929 7.375 7.5 7.375Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.5 13.625V12.375C12.5 11.712 12.2366 11.0761 11.7678 10.6072C11.2989 10.1384 10.663 9.875 10 9.875H5C4.33696 9.875 3.70107 10.1384 3.23223 10.6072C2.76339 11.0761 2.5 11.712 2.5 12.375V13.625" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.5 7.375C8.88071 7.375 10 6.25571 10 4.875C10 3.49429 8.88071 2.375 7.5 2.375C6.11929 2.375 5 3.49429 5 4.875C5 6.25571 6.11929 7.375 7.5 7.375Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
diff --git a/client/web/src/assets/icons/x-circle.svg b/client/web/src/assets/icons/x-circle.svg
index 49afc5a03..d6259c917 100644
--- a/client/web/src/assets/icons/x-circle.svg
+++ b/client/web/src/assets/icons/x-circle.svg
@@ -1,5 +1,5 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M15 9L9 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9 9L15 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M15 9L9 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 9L15 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
diff --git a/client/web/synology.go b/client/web/synology.go
index 922489d78..548026383 100644
--- a/client/web/synology.go
+++ b/client/web/synology.go
@@ -1,59 +1,59 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-// synology.go contains handlers and logic, such as authentication,
-// that is specific to running the web client on Synology.
-
-package web
-
-import (
- "errors"
- "fmt"
- "net/http"
- "os/exec"
- "strings"
-
- "tailscale.com/util/groupmember"
-)
-
-// authorizeSynology authenticates the logged-in Synology user and verifies
-// that they are authorized to use the web client.
-// If the user is authenticated, but not authorized to use the client, an error is returned.
-func authorizeSynology(r *http.Request) (authorized bool, err error) {
- if !hasSynoToken(r) {
- return false, nil
- }
-
- // authenticate the Synology user
- cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
- out, err := cmd.CombinedOutput()
- if err != nil {
- return false, fmt.Errorf("auth: %v: %s", err, out)
- }
- user := strings.TrimSpace(string(out))
-
- // check if the user is in the administrators group
- isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
- if err != nil {
- return false, err
- }
- if !isAdmin {
- return false, errors.New("not a member of administrators group")
- }
-
- return true, nil
-}
-
-// hasSynoToken returns true if the request include a SynoToken used for synology auth.
-func hasSynoToken(r *http.Request) bool {
- if r.Header.Get("X-Syno-Token") != "" {
- return true
- }
- if r.URL.Query().Get("SynoToken") != "" {
- return true
- }
- if r.Method == "POST" && r.FormValue("SynoToken") != "" {
- return true
- }
- return false
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// synology.go contains handlers and logic, such as authentication,
+// that is specific to running the web client on Synology.
+
+package web
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "os/exec"
+ "strings"
+
+ "tailscale.com/util/groupmember"
+)
+
+// authorizeSynology authenticates the logged-in Synology user and verifies
+// that they are authorized to use the web client.
+// If the user is authenticated, but not authorized to use the client, an error is returned.
+func authorizeSynology(r *http.Request) (authorized bool, err error) {
+ if !hasSynoToken(r) {
+ return false, nil
+ }
+
+ // authenticate the Synology user
+ cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return false, fmt.Errorf("auth: %v: %s", err, out)
+ }
+ user := strings.TrimSpace(string(out))
+
+ // check if the user is in the administrators group
+ isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
+ if err != nil {
+ return false, err
+ }
+ if !isAdmin {
+ return false, errors.New("not a member of administrators group")
+ }
+
+ return true, nil
+}
+
+// hasSynoToken returns true if the request include a SynoToken used for synology auth.
+func hasSynoToken(r *http.Request) bool {
+ if r.Header.Get("X-Syno-Token") != "" {
+ return true
+ }
+ if r.URL.Query().Get("SynoToken") != "" {
+ return true
+ }
+ if r.Method == "POST" && r.FormValue("SynoToken") != "" {
+ return true
+ }
+ return false
+}