package webui import ( "bytes" "context" "encoding/json" "fmt" "net" "net/url" "os/exec" "slices" "strings" "time" ) // TailscaleNetwork is the range Tailscale hands its devices addresses from. // Its traffic is authenticated and encrypted device to device, which is why // `albedo web` shares to it without being asked. var TailscaleNetwork = &net.IPNet{IP: net.IPv4(100, 64, 0, 0).To4(), Mask: net.CIDRMask(10, 32)} // Tailnet is this machine on a Tailscale network: the names other devices // reach it by (MagicDNS, whose suffix is the tailnet's own), and the // certificate domains the control server reports. type Tailnet struct { Names []string certDomains []string } // ServeHTTPS is the claim on `tailscale serve` for the listener on port. It // returns an error naming the fix when the machine is not on a tailnet, or the // control server offers no certificate for its MagicDNS name (Headscale // without node certificates, or HTTPS turned off), before anything is changed. func (t Tailnet) ServeHTTPS(port int) (Serve, error) { if len(t.Names) == 0 { return Serve{}, fmt.Errorf("--tailnet-https needs this machine signed in to Tailscale") } if !slices.ContainsFunc(t.certDomains, func(domain string) bool { return certificateCovers(domain, t.Names[0]) }) { return Serve{}, fmt.Errorf("this tailnet issues no certificate for %s: turn on HTTPS certificates in the Tailscale admin console, or dns.certificates on Headscale; a proxy that terminates TLS uses --public-url", t.Names[0]) } return NewServe(port), nil } // certificateCovers reports whether a certificate domain covers name. A // wildcard covers exactly one label. func certificateCovers(domain, name string) bool { domain = strings.TrimSuffix(strings.ToLower(domain), ".") name = strings.ToLower(name) if domain == name { return true } suffix, wildcard := strings.CutPrefix(domain, "*.") if !wildcard { return false } label, rest, found := strings.Cut(name, ".") return found && label != "" && rest == suffix } // CurrentTailnet asks the tailscale CLI who this machine is, and reports // false when it is not installed, not running or not signed in. func CurrentTailnet(ctx context.Context) (Tailnet, bool) { out, err := runTailscale(ctx, "status", "--json", "--peers=false") if err != nil { return Tailnet{}, false } return parseTailnet([]byte(out)) } func parseTailnet(status []byte) (Tailnet, bool) { var parsed struct { BackendState string Self struct{ DNSName string } CertDomains []string } if err := json.Unmarshal(status, &parsed); err != nil || parsed.BackendState != "Running" { return Tailnet{}, false } full := strings.ToLower(strings.TrimSuffix(parsed.Self.DNSName, ".")) if full == "" { return Tailnet{}, false } short, _, _ := strings.Cut(full, ".") return Tailnet{Names: []string{full, short}, certDomains: parsed.CertDomains}, true } // Serve is a listener's claim on the node's https:443 root, which `tailscale // serve` publishes with a certificate for the machine's MagicDNS name. type Serve struct{ target string } // NewServe is the claim for the listener on port. func NewServe(port int) Serve { return Serve{target: Loopback(port).String()} } // Claim points the https:443 root at this listener, or reuses one that already // does, and returns the https origin it serves. It refuses a root that // publishes anything else, or that Funnel opens to the internet. A handler it // created but cannot confirm is removed before it returns. func (s Serve) Claim(ctx context.Context) (*url.URL, error) { root, err := s.root(ctx) if err != nil { return nil, fmt.Errorf("cannot read the tailscale serve config: %w", err) } if root.ours == "" && len(root.busy) > 0 { return nil, fmt.Errorf("tailscale serve already publishes %s; run `tailscale serve status` to see it, or pass --public-url with the address it serves", strings.Join(root.busy, ", ")) } if root.ours == "" { // tailscale serve exits 0 after printing how to enable HTTPS when // the tailnet has not, so the config read back is the answer. output, err := runTailscale(ctx, "serve", "--bg", "--https=443", s.target) if err == nil { root, err = s.root(ctx) } if err == nil && root.ours == "" { detail := strings.TrimSpace(output) if detail == "" { detail = "check `tailscale serve status`" } err = fmt.Errorf("tailscale serve did not keep the https handler: %s", detail) } if err != nil { if released := s.Release(context.WithoutCancel(ctx)); released != nil { err = fmt.Errorf("%w (and could not remove the handler it may have created: %v)", err, released) } return nil, err } } return ParseOrigin("https://" + root.ours) } // Release removes the https:443 root while it still proxies this listener; one // someone replaced since is theirs and stays. func (s Serve) Release(ctx context.Context) error { root, err := s.root(ctx) if err != nil || root.ours == "" { return err } _, err = runTailscale(ctx, "serve", "--https=443", "--set-path", "/", "off") return err } func (s Serve) root(ctx context.Context) (serveRoot, error) { status, err := runTailscale(ctx, "serve", "status", "--json") if err != nil { return serveRoot{}, err } return parseServeRoot([]byte(status), s.target) } // serveRoot is what `tailscale serve status --json` says about the https:443 // root: ours is the host:port whose root proxies the target, and busy names // every published entry ("Web host:443", "TCP 22", an unknown key) otherwise. type serveRoot struct { ours string busy []string } // parseServeRoot finds the target's root. A key it does not know keeps the // root busy even when the target's root is there, since a newer tailscale may // publish through it, and so does Funnel on that root. func parseServeRoot(status []byte, target string) (serveRoot, error) { var fields map[string]json.RawMessage var config struct { Web map[string]struct { Handlers map[string]struct{ Proxy string } } AllowFunnel map[string]bool } if err := json.Unmarshal(status, &fields); err != nil { return serveRoot{}, fmt.Errorf("tailscale serve status: %w", err) } if err := json.Unmarshal(status, &config); err != nil { return serveRoot{}, fmt.Errorf("tailscale serve status: %w", err) } var busy []string known := true for key, value := range fields { var entries map[string]json.RawMessage if !slices.Contains([]string{"TCP", "Web", "Services", "AllowFunnel", "Foreground"}, key) || json.Unmarshal(value, &entries) != nil { busy, known = append(busy, key), false continue } for name := range entries { busy = append(busy, key+" "+name) } } for hostport, web := range config.Web { _, port, _ := net.SplitHostPort(hostport) if known && port == "443" && web.Handlers["/"].Proxy == target && !config.AllowFunnel[hostport] { return serveRoot{ours: hostport}, nil } } slices.Sort(busy) return serveRoot{busy: busy}, nil } // runTailscale runs the tailscale CLI, keeping its own message on failure, // which names the fix (not signed in, https off, no permission). func runTailscale(ctx context.Context, args ...string) (string, error) { ctx, cancel := context.WithTimeout(ctx, tailscaleTimeout) defer cancel() command := exec.CommandContext(ctx, "tailscale", args...) var stdout, stderr bytes.Buffer command.Stdout, command.Stderr = &stdout, &stderr if err := command.Run(); err != nil { if message := strings.TrimSpace(stderr.String() + stdout.String()); message != "" { return "", fmt.Errorf("%w: %s", err, message) } return "", err } return stdout.String(), nil } // tailscaleTimeout bounds one tailscale command. They only talk to the local // tailscaled, and a claim runs at most five, inside the background start's // budget. const tailscaleTimeout = 5 * time.Second