2023-01-27 13:37:20 -08:00
|
|
|
// Copyright (c) Tailscale Inc & AUTHORS
|
|
|
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
2021-02-15 10:41:52 -08:00
|
|
|
|
|
|
|
|
// Package localapi contains the HTTP server handlers for tailscaled's API server.
|
|
|
|
|
package localapi
|
|
|
|
|
|
|
|
|
|
import (
|
2022-03-29 12:43:26 -07:00
|
|
|
"bytes"
|
2024-05-18 14:37:37 -07:00
|
|
|
"cmp"
|
2025-11-14 12:58:53 -08:00
|
|
|
"crypto/subtle"
|
2021-02-15 10:41:52 -08:00
|
|
|
"encoding/json"
|
2021-04-16 10:57:46 -07:00
|
|
|
"errors"
|
2021-03-30 12:56:00 -07:00
|
|
|
"fmt"
|
2021-02-15 10:41:52 -08:00
|
|
|
"io"
|
2022-03-24 09:04:01 -07:00
|
|
|
"net"
|
2021-02-15 10:41:52 -08:00
|
|
|
"net/http"
|
2022-07-25 20:55:44 -07:00
|
|
|
"net/netip"
|
2021-03-30 12:56:00 -07:00
|
|
|
"net/url"
|
2021-03-05 12:07:00 -08:00
|
|
|
"runtime"
|
2023-08-16 22:09:53 -07:00
|
|
|
"slices"
|
2021-03-18 21:07:58 -07:00
|
|
|
"strconv"
|
2021-03-30 12:56:00 -07:00
|
|
|
"strings"
|
2022-07-08 11:57:34 -07:00
|
|
|
"sync"
|
2021-03-30 15:59:44 -07:00
|
|
|
"time"
|
2021-02-15 10:41:52 -08:00
|
|
|
|
2024-09-24 13:18:45 -07:00
|
|
|
"golang.org/x/net/dns/dnsmessage"
|
2021-04-13 08:13:46 -07:00
|
|
|
"tailscale.com/client/tailscale/apitype"
|
2022-09-13 07:09:57 -07:00
|
|
|
"tailscale.com/envknob"
|
2025-09-26 16:41:26 -07:00
|
|
|
"tailscale.com/feature"
|
2025-09-28 10:57:22 -07:00
|
|
|
"tailscale.com/feature/buildfeatures"
|
2025-05-22 20:12:59 +01:00
|
|
|
"tailscale.com/health/healthmsg"
|
2022-10-03 10:54:46 -04:00
|
|
|
"tailscale.com/hostinfo"
|
2021-04-11 16:10:31 -07:00
|
|
|
"tailscale.com/ipn"
|
2023-11-09 13:55:46 -07:00
|
|
|
"tailscale.com/ipn/ipnauth"
|
2021-02-15 10:41:52 -08:00
|
|
|
"tailscale.com/ipn/ipnlocal"
|
2021-03-18 21:07:58 -07:00
|
|
|
"tailscale.com/ipn/ipnstate"
|
2022-12-23 20:54:30 -08:00
|
|
|
"tailscale.com/logtail"
|
2022-03-24 09:04:01 -07:00
|
|
|
"tailscale.com/net/netutil"
|
2021-02-15 10:41:52 -08:00
|
|
|
"tailscale.com/tailcfg"
|
2023-07-27 15:41:31 -04:00
|
|
|
"tailscale.com/tstime"
|
2025-10-02 09:31:42 -07:00
|
|
|
"tailscale.com/types/appctype"
|
2022-10-31 16:47:51 -07:00
|
|
|
"tailscale.com/types/key"
|
2021-03-30 15:59:44 -07:00
|
|
|
"tailscale.com/types/logger"
|
2023-03-23 10:49:56 -07:00
|
|
|
"tailscale.com/types/logid"
|
2022-12-01 14:39:03 -08:00
|
|
|
"tailscale.com/types/ptr"
|
2020-12-13 18:51:24 -08:00
|
|
|
"tailscale.com/util/clientmetric"
|
2025-03-19 10:47:25 -07:00
|
|
|
"tailscale.com/util/eventbus"
|
2023-01-26 19:35:26 -08:00
|
|
|
"tailscale.com/util/httpm"
|
2022-09-10 12:11:59 -07:00
|
|
|
"tailscale.com/util/mak"
|
go.mod, cmd/tailscaled, ipn/localapi, util/osdiag, util/winutil, util/winutil/authenticode: add Windows module list to OS-specific logs that are written upon bugreport
* We update wingoes to pick up new version information functionality
(See pe/version.go in the https://github.com/dblohm7/wingoes repo);
* We move the existing LogSupportInfo code (including necessary syscall
stubs) out of util/winutil into a new package, util/osdiag, and implement
the public LogSupportInfo function may be implemented for other platforms
as needed;
* We add a new reason argument to LogSupportInfo and wire that into
localapi's bugreport implementation;
* We add module information to the Windows implementation of LogSupportInfo
when reason indicates a bugreport. We enumerate all loaded modules in our
process, and for each one we gather debug, authenticode signature, and
version information.
Fixes #7802
Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-06-26 11:50:45 -06:00
|
|
|
"tailscale.com/util/osdiag"
|
2023-09-05 09:02:40 -07:00
|
|
|
"tailscale.com/util/rands"
|
2025-09-24 18:37:42 -05:00
|
|
|
"tailscale.com/util/syspolicy/pkey"
|
2021-08-19 08:36:13 -07:00
|
|
|
"tailscale.com/version"
|
2023-09-22 17:49:09 +02:00
|
|
|
"tailscale.com/wgengine/magicsock"
|
2021-02-15 10:41:52 -08:00
|
|
|
)
|
|
|
|
|
|
2025-05-09 12:03:22 -04:00
|
|
|
var (
|
|
|
|
|
metricInvalidRequests = clientmetric.NewCounter("localapi_invalid_requests")
|
|
|
|
|
metricDebugMetricsCalls = clientmetric.NewCounter("localapi_debugmetric_requests")
|
|
|
|
|
metricUserMetricsCalls = clientmetric.NewCounter("localapi_usermetric_requests")
|
|
|
|
|
metricBugReportRequests = clientmetric.NewCounter("localapi_bugreport_requests")
|
|
|
|
|
)
|
|
|
|
|
|
2025-01-23 20:39:28 -08:00
|
|
|
type LocalAPIHandler func(*Handler, http.ResponseWriter, *http.Request)
|
2022-10-09 08:57:02 -07:00
|
|
|
|
|
|
|
|
// handler is the set of LocalAPI handlers, keyed by the part of the
|
|
|
|
|
// Request.URL.Path after "/localapi/v0/". If the key ends with a trailing slash
|
|
|
|
|
// then it's a prefix match.
|
2025-01-23 20:39:28 -08:00
|
|
|
var handler = map[string]LocalAPIHandler{
|
2022-10-09 08:57:02 -07:00
|
|
|
// The prefix match handlers end with a slash:
|
2023-11-02 12:34:28 -04:00
|
|
|
"profiles/": (*Handler).serveProfiles,
|
2022-10-09 08:57:02 -07:00
|
|
|
|
|
|
|
|
// The other /localapi/v0/NAME handlers are exact matches and contain only NAME
|
|
|
|
|
// without a trailing slash:
|
2025-10-07 07:34:29 -07:00
|
|
|
"check-prefs": (*Handler).serveCheckPrefs,
|
|
|
|
|
"derpmap": (*Handler).serveDERPMap,
|
|
|
|
|
"goroutines": (*Handler).serveGoroutines,
|
|
|
|
|
"login-interactive": (*Handler).serveLoginInteractive,
|
|
|
|
|
"logout": (*Handler).serveLogout,
|
|
|
|
|
"ping": (*Handler).servePing,
|
|
|
|
|
"prefs": (*Handler).servePrefs,
|
|
|
|
|
"reload-config": (*Handler).reloadConfig,
|
|
|
|
|
"reset-auth": (*Handler).serveResetAuth,
|
|
|
|
|
"set-expiry-sooner": (*Handler).serveSetExpirySooner,
|
|
|
|
|
"shutdown": (*Handler).serveShutdown,
|
|
|
|
|
"start": (*Handler).serveStart,
|
|
|
|
|
"status": (*Handler).serveStatus,
|
|
|
|
|
"whois": (*Handler).serveWhoIs,
|
2022-10-09 08:57:02 -07:00
|
|
|
}
|
|
|
|
|
|
2025-09-30 13:11:48 -07:00
|
|
|
func init() {
|
|
|
|
|
if buildfeatures.HasAppConnectors {
|
|
|
|
|
Register("appc-route-info", (*Handler).serveGetAppcRouteInfo)
|
|
|
|
|
}
|
2025-10-06 09:03:10 -07:00
|
|
|
if buildfeatures.HasAdvertiseRoutes {
|
|
|
|
|
Register("check-ip-forwarding", (*Handler).serveCheckIPForwarding)
|
2025-10-07 07:34:29 -07:00
|
|
|
Register("check-udp-gro-forwarding", (*Handler).serveCheckUDPGROForwarding)
|
|
|
|
|
Register("set-udp-gro-forwarding", (*Handler).serveSetUDPGROForwarding)
|
|
|
|
|
}
|
|
|
|
|
if buildfeatures.HasUseExitNode && runtime.GOOS == "linux" {
|
|
|
|
|
Register("check-reverse-path-filtering", (*Handler).serveCheckReversePathFiltering)
|
|
|
|
|
}
|
|
|
|
|
if buildfeatures.HasClientMetrics {
|
|
|
|
|
Register("upload-client-metrics", (*Handler).serveUploadClientMetrics)
|
|
|
|
|
}
|
|
|
|
|
if buildfeatures.HasClientUpdate {
|
|
|
|
|
Register("update/check", (*Handler).serveUpdateCheck)
|
2025-10-06 09:03:10 -07:00
|
|
|
}
|
2025-10-01 19:18:46 -07:00
|
|
|
if buildfeatures.HasUseExitNode {
|
|
|
|
|
Register("suggest-exit-node", (*Handler).serveSuggestExitNode)
|
|
|
|
|
Register("set-use-exit-node-enabled", (*Handler).serveSetUseExitNodeEnabled)
|
|
|
|
|
}
|
|
|
|
|
if buildfeatures.HasACME {
|
|
|
|
|
Register("set-dns", (*Handler).serveSetDNS)
|
|
|
|
|
}
|
|
|
|
|
if buildfeatures.HasDebug {
|
|
|
|
|
Register("bugreport", (*Handler).serveBugReport)
|
|
|
|
|
Register("pprof", (*Handler).servePprof)
|
|
|
|
|
}
|
2025-10-07 07:34:29 -07:00
|
|
|
if buildfeatures.HasDebug || buildfeatures.HasServe {
|
|
|
|
|
Register("watch-ipn-bus", (*Handler).serveWatchIPNBus)
|
|
|
|
|
}
|
2025-10-06 09:03:10 -07:00
|
|
|
if buildfeatures.HasDNS {
|
|
|
|
|
Register("dns-osconfig", (*Handler).serveDNSOSConfig)
|
|
|
|
|
Register("dns-query", (*Handler).serveDNSQuery)
|
|
|
|
|
}
|
2025-10-06 12:02:16 -07:00
|
|
|
if buildfeatures.HasUserMetrics {
|
|
|
|
|
Register("usermetrics", (*Handler).serveUserMetrics)
|
|
|
|
|
}
|
2025-10-07 07:34:29 -07:00
|
|
|
if buildfeatures.HasServe {
|
|
|
|
|
Register("query-feature", (*Handler).serveQueryFeature)
|
|
|
|
|
}
|
|
|
|
|
if buildfeatures.HasOutboundProxy || buildfeatures.HasSSH {
|
|
|
|
|
Register("dial", (*Handler).serveDial)
|
|
|
|
|
}
|
|
|
|
|
if buildfeatures.HasClientMetrics || buildfeatures.HasDebug {
|
|
|
|
|
Register("metrics", (*Handler).serveMetrics)
|
|
|
|
|
}
|
|
|
|
|
if buildfeatures.HasDebug || buildfeatures.HasAdvertiseRoutes {
|
|
|
|
|
Register("disconnect-control", (*Handler).disconnectControl)
|
|
|
|
|
}
|
|
|
|
|
// Alpha/experimental/debug features. These should be moved to
|
|
|
|
|
// their own features if/when they graduate.
|
|
|
|
|
if buildfeatures.HasDebug {
|
|
|
|
|
Register("id-token", (*Handler).serveIDToken)
|
|
|
|
|
Register("alpha-set-device-attrs", (*Handler).serveSetDeviceAttrs) // see tailscale/corp#24690
|
|
|
|
|
Register("handle-push-message", (*Handler).serveHandlePushMessage)
|
|
|
|
|
Register("set-push-device-token", (*Handler).serveSetPushDeviceToken)
|
|
|
|
|
}
|
|
|
|
|
if buildfeatures.HasDebug || runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
|
|
|
|
Register("set-gui-visible", (*Handler).serveSetGUIVisible)
|
|
|
|
|
}
|
|
|
|
|
if buildfeatures.HasLogTail {
|
|
|
|
|
// TODO(bradfitz): separate out logtail tap functionality from upload
|
|
|
|
|
// functionality to make this possible? But seems unlikely people would
|
|
|
|
|
// want just this. They could "tail -f" or "journalctl -f" their logs
|
|
|
|
|
// themselves.
|
|
|
|
|
Register("logtap", (*Handler).serveLogTap)
|
|
|
|
|
}
|
2025-09-30 13:11:48 -07:00
|
|
|
}
|
|
|
|
|
|
2025-01-23 20:39:28 -08:00
|
|
|
// Register registers a new LocalAPI handler for the given name.
|
|
|
|
|
func Register(name string, fn LocalAPIHandler) {
|
|
|
|
|
if _, ok := handler[name]; ok {
|
|
|
|
|
panic("duplicate LocalAPI handler registration: " + name)
|
|
|
|
|
}
|
|
|
|
|
handler[name] = fn
|
|
|
|
|
}
|
|
|
|
|
|
2022-07-08 11:57:34 -07:00
|
|
|
var (
|
|
|
|
|
// The clientmetrics package is stateful, but we want to expose a simple
|
|
|
|
|
// imperative API to local clients, so we need to keep track of
|
|
|
|
|
// clientmetric.Metric instances that we've created for them. These need to
|
|
|
|
|
// be globals because we end up creating many Handler instances for the
|
|
|
|
|
// lifetime of a client.
|
|
|
|
|
metricsMu sync.Mutex
|
|
|
|
|
metrics = map[string]*clientmetric.Metric{}
|
|
|
|
|
)
|
|
|
|
|
|
2025-08-18 10:56:17 -07:00
|
|
|
// NewHandler creates a new LocalAPI HTTP handler from the given config.
|
|
|
|
|
func NewHandler(cfg HandlerConfig) *Handler {
|
|
|
|
|
return &Handler{
|
|
|
|
|
Actor: cfg.Actor,
|
|
|
|
|
b: cfg.Backend,
|
|
|
|
|
logf: cfg.Logf,
|
|
|
|
|
backendLogID: cfg.LogID,
|
|
|
|
|
clock: tstime.StdClock{},
|
|
|
|
|
eventBus: cfg.EventBus,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HandlerConfig carries the settings for a local API handler.
|
|
|
|
|
// All fields are required.
|
|
|
|
|
type HandlerConfig struct {
|
|
|
|
|
Actor ipnauth.Actor
|
|
|
|
|
Backend *ipnlocal.LocalBackend
|
|
|
|
|
Logf logger.Logf
|
|
|
|
|
LogID logid.PublicID
|
|
|
|
|
EventBus *eventbus.Bus
|
2021-02-15 10:41:52 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Handler struct {
|
|
|
|
|
// RequiredPassword, if non-empty, forces all HTTP
|
|
|
|
|
// requests to have HTTP basic auth with this password.
|
|
|
|
|
// It's used by the sandboxed macOS sameuserproof GUI auth mechanism.
|
|
|
|
|
RequiredPassword string
|
|
|
|
|
|
|
|
|
|
// PermitRead is whether read-only HTTP handlers are allowed.
|
|
|
|
|
PermitRead bool
|
|
|
|
|
|
|
|
|
|
// PermitWrite is whether mutating HTTP handlers are allowed.
|
2022-01-25 10:33:11 -08:00
|
|
|
// If PermitWrite is true, everything is allowed.
|
|
|
|
|
// It effectively means that the user is root or the admin
|
|
|
|
|
// (operator user).
|
2021-02-15 10:41:52 -08:00
|
|
|
PermitWrite bool
|
|
|
|
|
|
2022-01-25 10:33:11 -08:00
|
|
|
// PermitCert is whether the client is additionally granted
|
|
|
|
|
// cert fetching access.
|
|
|
|
|
PermitCert bool
|
|
|
|
|
|
ipn/{ipnauth,ipnlocal,ipnserver,localapi}: start baby step toward moving access checks from the localapi.Handler to the LocalBackend
Currently, we use PermitRead/PermitWrite/PermitCert permission flags to determine which operations are allowed for a LocalAPI client.
These checks are performed when localapi.Handler handles a request. Additionally, certain operations (e.g., changing the serve config)
requires the connected user to be a local admin. This approach is inherently racey and is subject to TOCTOU issues.
We consider it to be more critical on Windows environments, which are inherently multi-user, and therefore we prevent more than one
OS user from connecting and utilizing the LocalBackend at the same time. However, the same type of issues is also applicable to other
platforms when switching between profiles that have different OperatorUser values in ipn.Prefs.
We'd like to allow more than one Windows user to connect, but limit what they can see and do based on their access rights on the device
(e.g., an local admin or not) and to the currently active LoginProfile (e.g., owner/operator or not), while preventing TOCTOU issues on Windows
and other platforms. Therefore, we'd like to pass an actor from the LocalAPI to the LocalBackend to represent the user performing the operation.
The LocalBackend, or the profileManager down the line, will then check the actor's access rights to perform a given operation on the device
and against the current (and/or the target) profile.
This PR does not change the current permission model in any way, but it introduces the concept of an actor and includes some preparatory
work to pass it around. Temporarily, the ipnauth.Actor interface has methods like IsLocalSystem and IsLocalAdmin, which are only relevant
to the current permission model. It also lacks methods that will actually be used in the new model. We'll be adding these gradually in the next
PRs and removing the deprecated methods and the Permit* flags at the end of the transition.
Updates tailscale/corp#18342
Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-08-27 15:22:56 -05:00
|
|
|
// Actor is the identity of the client connected to the Handler.
|
|
|
|
|
Actor ipnauth.Actor
|
ipn, safesocket: use Windows token in LocalAPI
On Windows, the idiomatic way to check access on a named pipe is for
the server to impersonate the client on its current OS thread, perform
access checks using the client's access token, and then revert the OS
thread's access token back to its true self.
The access token is a better representation of the client's rights than just
a username/userid check, as it represents the client's effective rights
at connection time, which might differ from their normal rights.
This patch updates safesocket to do the aforementioned impersonation,
extract the token handle, and then revert the impersonation. We retain
the token handle for the remaining duration of the connection (the token
continues to be valid even after we have reverted back to self).
Since the token is a property of the connection, I changed ipnauth to wrap
the concrete net.Conn to include the token. I then plumbed that change
through ipnlocal, ipnserver, and localapi as necessary.
I also added a PermitLocalAdmin flag to the localapi Handler which I intend
to use for controlling access to a few new localapi endpoints intended
for configuring auto-update.
Updates https://github.com/tailscale/tailscale/issues/755
Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-10-25 14:48:05 -06:00
|
|
|
|
2021-03-30 15:59:44 -07:00
|
|
|
b *ipnlocal.LocalBackend
|
|
|
|
|
logf logger.Logf
|
2023-03-23 10:49:56 -07:00
|
|
|
backendLogID logid.PublicID
|
2023-07-27 15:41:31 -04:00
|
|
|
clock tstime.Clock
|
2025-08-18 10:56:17 -07:00
|
|
|
eventBus *eventbus.Bus // read-only after initialization
|
2021-02-15 10:41:52 -08:00
|
|
|
}
|
|
|
|
|
|
2025-04-15 08:28:48 -07:00
|
|
|
func (h *Handler) Logf(format string, args ...any) {
|
|
|
|
|
h.logf(format, args...)
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-23 20:39:28 -08:00
|
|
|
func (h *Handler) LocalBackend() *ipnlocal.LocalBackend {
|
|
|
|
|
return h.b
|
|
|
|
|
}
|
|
|
|
|
|
2021-02-15 10:41:52 -08:00
|
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if h.b == nil {
|
|
|
|
|
http.Error(w, "server has no local backend", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
2023-02-27 08:16:11 -08:00
|
|
|
if r.Referer() != "" || r.Header.Get("Origin") != "" || !h.validHost(r.Host) {
|
2022-12-09 14:21:53 -08:00
|
|
|
metricInvalidRequests.Add(1)
|
2022-11-16 07:19:07 -08:00
|
|
|
http.Error(w, "invalid localapi request", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2023-02-10 22:20:36 -08:00
|
|
|
w.Header().Set("Tailscale-Version", version.Long())
|
2023-01-19 12:40:58 -08:00
|
|
|
w.Header().Set("Tailscale-Cap", strconv.Itoa(int(tailcfg.CurrentCapabilityVersion)))
|
2022-11-17 09:24:21 -05:00
|
|
|
w.Header().Set("Content-Security-Policy", `default-src 'none'; frame-ancestors 'none'; script-src 'none'; script-src-elem 'none'; script-src-attr 'none'`)
|
|
|
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
|
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
2021-02-15 10:41:52 -08:00
|
|
|
if h.RequiredPassword != "" {
|
|
|
|
|
_, pass, ok := r.BasicAuth()
|
|
|
|
|
if !ok {
|
2022-12-09 14:21:53 -08:00
|
|
|
metricInvalidRequests.Add(1)
|
2021-02-15 10:41:52 -08:00
|
|
|
http.Error(w, "auth required", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-11-14 12:58:53 -08:00
|
|
|
if subtle.ConstantTimeCompare([]byte(pass), []byte(h.RequiredPassword)) == 0 {
|
2022-12-09 14:21:53 -08:00
|
|
|
metricInvalidRequests.Add(1)
|
2021-02-15 10:41:52 -08:00
|
|
|
http.Error(w, "bad password", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
2022-10-09 08:57:02 -07:00
|
|
|
if fn, ok := handlerForPath(r.URL.Path); ok {
|
|
|
|
|
fn(h, w, r)
|
|
|
|
|
} else {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-27 08:16:11 -08:00
|
|
|
// validLocalHostForTesting allows loopback handlers without RequiredPassword for testing.
|
|
|
|
|
var validLocalHostForTesting = false
|
2023-01-30 09:13:45 -08:00
|
|
|
|
2022-11-16 07:19:07 -08:00
|
|
|
// validHost reports whether h is a valid Host header value for a LocalAPI request.
|
2023-02-27 08:16:11 -08:00
|
|
|
func (h *Handler) validHost(hostname string) bool {
|
2022-11-16 07:19:07 -08:00
|
|
|
// The client code sends a hostname of "local-tailscaled.sock".
|
2023-02-27 08:16:11 -08:00
|
|
|
switch hostname {
|
2022-11-16 07:19:07 -08:00
|
|
|
case "", apitype.LocalAPIHost:
|
|
|
|
|
return true
|
|
|
|
|
}
|
2023-02-27 08:16:11 -08:00
|
|
|
if !validLocalHostForTesting && h.RequiredPassword == "" {
|
|
|
|
|
return false // only allow localhost with basic auth or in tests
|
2023-01-30 09:13:45 -08:00
|
|
|
}
|
2023-02-27 08:16:11 -08:00
|
|
|
host, _, err := net.SplitHostPort(hostname)
|
2022-11-16 07:19:07 -08:00
|
|
|
if err != nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2022-11-16 11:35:01 -08:00
|
|
|
if host == "localhost" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2023-01-30 09:13:45 -08:00
|
|
|
addr, err := netip.ParseAddr(host)
|
2022-11-16 11:35:01 -08:00
|
|
|
if err != nil {
|
2022-11-16 07:19:07 -08:00
|
|
|
return false
|
|
|
|
|
}
|
2022-11-16 11:35:01 -08:00
|
|
|
return addr.IsLoopback()
|
2022-11-16 07:19:07 -08:00
|
|
|
}
|
|
|
|
|
|
2022-10-09 08:57:02 -07:00
|
|
|
// handlerForPath returns the LocalAPI handler for the provided Request.URI.Path.
|
|
|
|
|
// (the path doesn't include any query parameters)
|
2025-01-23 20:39:28 -08:00
|
|
|
func handlerForPath(urlPath string) (h LocalAPIHandler, ok bool) {
|
2022-10-09 08:57:02 -07:00
|
|
|
if urlPath == "/" {
|
|
|
|
|
return (*Handler).serveLocalAPIRoot, true
|
|
|
|
|
}
|
2023-02-01 13:43:06 -08:00
|
|
|
suff, ok := strings.CutPrefix(urlPath, "/localapi/v0/")
|
2022-10-09 08:57:02 -07:00
|
|
|
if !ok {
|
|
|
|
|
// Currently all LocalAPI methods start with "/localapi/v0/" to signal
|
|
|
|
|
// to people that they're not necessarily stable APIs. In practice we'll
|
|
|
|
|
// probably need to keep them pretty stable anyway, but for now treat
|
|
|
|
|
// them as an internal implementation detail.
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
if fn, ok := handler[suff]; ok {
|
|
|
|
|
// Here we match exact handler suffixes like "status" or ones with a
|
|
|
|
|
// slash already in their name, like "tka/status".
|
|
|
|
|
return fn, true
|
|
|
|
|
}
|
|
|
|
|
// Otherwise, it might be a prefix match like "files/*" which we look up
|
|
|
|
|
// by the prefix including first trailing slash.
|
|
|
|
|
if i := strings.IndexByte(suff, '/'); i != -1 {
|
|
|
|
|
suff = suff[:i+1]
|
|
|
|
|
if fn, ok := handler[suff]; ok {
|
|
|
|
|
return fn, true
|
|
|
|
|
}
|
2021-02-15 10:41:52 -08:00
|
|
|
}
|
2022-10-09 08:57:02 -07:00
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (*Handler) serveLocalAPIRoot(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
io.WriteString(w, "tailscaled\n")
|
2021-02-15 10:41:52 -08:00
|
|
|
}
|
|
|
|
|
|
2022-03-29 12:43:26 -07:00
|
|
|
// serveIDToken handles requests to get an OIDC ID token.
|
|
|
|
|
func (h *Handler) serveIDToken(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "id-token access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
nm := h.b.NetMap()
|
|
|
|
|
if nm == nil {
|
|
|
|
|
http.Error(w, "no netmap", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
aud := strings.TrimSpace(r.FormValue("aud"))
|
|
|
|
|
if len(aud) == 0 {
|
|
|
|
|
http.Error(w, "no audience requested", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
req := &tailcfg.TokenRequest{
|
|
|
|
|
CapVersion: tailcfg.CurrentCapabilityVersion,
|
|
|
|
|
Audience: aud,
|
|
|
|
|
NodeKey: nm.NodeKey,
|
|
|
|
|
}
|
|
|
|
|
b, err := json.Marshal(req)
|
|
|
|
|
if err != nil {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
2022-03-29 12:43:26 -07:00
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
httpReq, err := http.NewRequest(httpm.POST, "https://unused/machine/id-token", bytes.NewReader(b))
|
2022-03-29 12:43:26 -07:00
|
|
|
if err != nil {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
2022-03-29 12:43:26 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
resp, err := h.b.DoNoiseRequest(httpReq)
|
|
|
|
|
if err != nil {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
2022-03-29 12:43:26 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
w.WriteHeader(resp.StatusCode)
|
|
|
|
|
if _, err := io.Copy(w, resp.Body); err != nil {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
2022-03-29 12:43:26 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-03-30 15:59:44 -07:00
|
|
|
func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitRead {
|
|
|
|
|
http.Error(w, "bugreport access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.POST {
|
2022-11-17 21:40:40 -08:00
|
|
|
http.Error(w, "only POST allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
2023-01-05 11:00:42 -08:00
|
|
|
defer h.b.TryFlushLogs() // kick off upload after bugreport's done logging
|
2021-03-30 15:59:44 -07:00
|
|
|
|
2022-10-15 19:31:35 +02:00
|
|
|
logMarker := func() string {
|
2023-09-05 09:02:40 -07:00
|
|
|
return fmt.Sprintf("BUG-%v-%v-%v", h.backendLogID, h.clock.Now().UTC().Format("20060102150405Z"), rands.HexString(16))
|
2022-10-15 19:31:35 +02:00
|
|
|
}
|
2022-09-13 07:09:57 -07:00
|
|
|
if envknob.NoLogsNoSupport() {
|
2022-10-15 19:31:35 +02:00
|
|
|
logMarker = func() string { return "BUG-NO-LOGS-NO-SUPPORT-this-node-has-had-its-logging-disabled" }
|
2022-09-13 07:09:57 -07:00
|
|
|
}
|
2022-10-15 19:31:35 +02:00
|
|
|
|
|
|
|
|
startMarker := logMarker()
|
|
|
|
|
h.logf("user bugreport: %s", startMarker)
|
|
|
|
|
if note := r.URL.Query().Get("note"); len(note) > 0 {
|
2021-03-30 15:59:44 -07:00
|
|
|
h.logf("user bugreport note: %s", note)
|
|
|
|
|
}
|
2022-10-03 10:54:46 -04:00
|
|
|
hi, _ := json.Marshal(hostinfo.New())
|
|
|
|
|
h.logf("user bugreport hostinfo: %s", hi)
|
2024-04-26 08:06:06 -07:00
|
|
|
if err := h.b.HealthTracker().OverallError(); err != nil {
|
2022-10-03 10:54:46 -04:00
|
|
|
h.logf("user bugreport health: %s", err.Error())
|
|
|
|
|
} else {
|
|
|
|
|
h.logf("user bugreport health: ok")
|
|
|
|
|
}
|
2023-02-07 11:43:55 -05:00
|
|
|
|
|
|
|
|
// Information about the current node from the netmap
|
2023-02-03 10:53:08 -05:00
|
|
|
if nm := h.b.NetMap(); nm != nil {
|
2023-08-21 10:53:57 -07:00
|
|
|
if self := nm.SelfNode; self.Valid() {
|
|
|
|
|
h.logf("user bugreport node info: nodeid=%q stableid=%q expiry=%q", self.ID(), self.StableID(), self.KeyExpiry().Format(time.RFC3339))
|
2023-02-03 10:53:08 -05:00
|
|
|
}
|
|
|
|
|
h.logf("user bugreport public keys: machine=%q node=%q", nm.MachineKey, nm.NodeKey)
|
|
|
|
|
} else {
|
|
|
|
|
h.logf("user bugreport netmap: no active netmap")
|
|
|
|
|
}
|
2023-02-07 11:43:55 -05:00
|
|
|
|
|
|
|
|
// Print all envknobs; we otherwise only print these on startup, and
|
|
|
|
|
// printing them here ensures we don't have to go spelunking through
|
|
|
|
|
// logs for them.
|
|
|
|
|
envknob.LogCurrent(logger.WithPrefix(h.logf, "user bugreport: "))
|
|
|
|
|
|
go.mod, cmd/tailscaled, ipn/localapi, util/osdiag, util/winutil, util/winutil/authenticode: add Windows module list to OS-specific logs that are written upon bugreport
* We update wingoes to pick up new version information functionality
(See pe/version.go in the https://github.com/dblohm7/wingoes repo);
* We move the existing LogSupportInfo code (including necessary syscall
stubs) out of util/winutil into a new package, util/osdiag, and implement
the public LogSupportInfo function may be implemented for other platforms
as needed;
* We add a new reason argument to LogSupportInfo and wire that into
localapi's bugreport implementation;
* We add module information to the Windows implementation of LogSupportInfo
when reason indicates a bugreport. We enumerate all loaded modules in our
process, and for each one we gather debug, authenticode signature, and
version information.
Fixes #7802
Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-06-26 11:50:45 -06:00
|
|
|
// OS-specific details
|
2024-04-22 16:45:01 -07:00
|
|
|
h.logf.JSON(1, "UserBugReportOS", osdiag.SupportInfo(osdiag.LogSupportInfoReasonBugReport))
|
go.mod, cmd/tailscaled, ipn/localapi, util/osdiag, util/winutil, util/winutil/authenticode: add Windows module list to OS-specific logs that are written upon bugreport
* We update wingoes to pick up new version information functionality
(See pe/version.go in the https://github.com/dblohm7/wingoes repo);
* We move the existing LogSupportInfo code (including necessary syscall
stubs) out of util/winutil into a new package, util/osdiag, and implement
the public LogSupportInfo function may be implemented for other platforms
as needed;
* We add a new reason argument to LogSupportInfo and wire that into
localapi's bugreport implementation;
* We add module information to the Windows implementation of LogSupportInfo
when reason indicates a bugreport. We enumerate all loaded modules in our
process, and for each one we gather debug, authenticode signature, and
version information.
Fixes #7802
Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-06-26 11:50:45 -06:00
|
|
|
|
2025-10-16 11:13:41 +01:00
|
|
|
// Tailnet Lock details
|
2025-05-12 19:43:25 +01:00
|
|
|
st := h.b.NetworkLockStatus()
|
|
|
|
|
if st.Enabled {
|
|
|
|
|
h.logf.JSON(1, "UserBugReportTailnetLockStatus", st)
|
|
|
|
|
if st.NodeKeySignature != nil {
|
|
|
|
|
h.logf("user bugreport tailnet lock signature: %s", st.NodeKeySignature.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-10-15 19:31:35 +02:00
|
|
|
if defBool(r.URL.Query().Get("diagnose"), false) {
|
2025-09-26 13:33:08 -07:00
|
|
|
if f, ok := ipnlocal.HookDoctor.GetOk(); ok {
|
|
|
|
|
f(r.Context(), h.b, logger.WithPrefix(h.logf, "diag: "))
|
|
|
|
|
}
|
2022-09-26 13:07:28 -04:00
|
|
|
}
|
2021-03-30 15:59:44 -07:00
|
|
|
w.Header().Set("Content-Type", "text/plain")
|
2022-10-15 19:31:35 +02:00
|
|
|
fmt.Fprintln(w, startMarker)
|
|
|
|
|
|
|
|
|
|
// Nothing else to do if we're not in record mode; we wrote the marker
|
|
|
|
|
// above, so we can just finish our response now.
|
|
|
|
|
if !defBool(r.URL.Query().Get("record"), false) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-27 15:41:31 -04:00
|
|
|
until := h.clock.Now().Add(12 * time.Hour)
|
2022-10-15 19:31:35 +02:00
|
|
|
|
|
|
|
|
var changed map[string]bool
|
|
|
|
|
for _, component := range []string{"magicsock"} {
|
|
|
|
|
if h.b.GetComponentDebugLogging(component).IsZero() {
|
|
|
|
|
if err := h.b.SetComponentDebugLogging(component, until); err != nil {
|
|
|
|
|
h.logf("bugreport: error setting component %q logging: %v", component, err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mak.Set(&changed, component, true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
defer func() {
|
|
|
|
|
for component := range changed {
|
|
|
|
|
h.b.SetComponentDebugLogging(component, time.Time{})
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// NOTE(andrew): if we have anything else we want to do while recording
|
|
|
|
|
// a bugreport, we can add it here.
|
|
|
|
|
|
2025-05-09 12:03:22 -04:00
|
|
|
metricBugReportRequests.Add(1)
|
|
|
|
|
|
2022-10-15 19:31:35 +02:00
|
|
|
// Read from the client; this will also return when the client closes
|
|
|
|
|
// the connection.
|
|
|
|
|
var buf [1]byte
|
|
|
|
|
_, err := r.Body.Read(buf[:])
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
case err == nil:
|
|
|
|
|
// good
|
|
|
|
|
case errors.Is(err, io.EOF):
|
|
|
|
|
// good
|
|
|
|
|
case errors.Is(err, io.ErrUnexpectedEOF):
|
|
|
|
|
// this happens when Ctrl-C'ing the tailscale client; don't
|
|
|
|
|
// bother logging an error
|
|
|
|
|
default:
|
|
|
|
|
// Log but continue anyway.
|
|
|
|
|
h.logf("user bugreport: error reading body: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate another log marker and return it to the client.
|
|
|
|
|
endMarker := logMarker()
|
|
|
|
|
h.logf("user bugreport end: %s", endMarker)
|
|
|
|
|
fmt.Fprintln(w, endMarker)
|
2021-03-30 15:59:44 -07:00
|
|
|
}
|
|
|
|
|
|
2021-02-15 10:41:52 -08:00
|
|
|
func (h *Handler) serveWhoIs(w http.ResponseWriter, r *http.Request) {
|
2023-10-10 10:39:08 -07:00
|
|
|
h.serveWhoIsWithBackend(w, r, h.b)
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-19 20:17:58 -08:00
|
|
|
// serveSetDeviceAttrs is (as of 2024-12-30) an experimental LocalAPI handler to
|
|
|
|
|
// set device attributes via the control plane.
|
|
|
|
|
//
|
|
|
|
|
// See tailscale/corp#24690.
|
|
|
|
|
func (h *Handler) serveSetDeviceAttrs(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "set-device-attrs access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.PATCH {
|
2024-11-19 20:17:58 -08:00
|
|
|
http.Error(w, "only PATCH allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var req map[string]any
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := h.b.SetDeviceAttrs(ctx, req); err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
io.WriteString(w, "{}\n")
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-10 10:39:08 -07:00
|
|
|
// localBackendWhoIsMethods is the subset of ipn.LocalBackend as needed
|
|
|
|
|
// by the localapi WhoIs method.
|
|
|
|
|
type localBackendWhoIsMethods interface {
|
2024-06-06 14:48:40 -04:00
|
|
|
WhoIs(string, netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
|
2024-06-14 08:05:47 -07:00
|
|
|
WhoIsNodeKey(key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
|
2023-10-10 10:39:08 -07:00
|
|
|
PeerCaps(netip.Addr) tailcfg.PeerCapMap
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) serveWhoIsWithBackend(w http.ResponseWriter, r *http.Request, b localBackendWhoIsMethods) {
|
2021-02-15 10:41:52 -08:00
|
|
|
if !h.PermitRead {
|
|
|
|
|
http.Error(w, "whois access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2024-06-14 08:05:47 -07:00
|
|
|
var (
|
|
|
|
|
n tailcfg.NodeView
|
|
|
|
|
u tailcfg.UserProfile
|
|
|
|
|
ok bool
|
|
|
|
|
)
|
all: convert more code to use net/netip directly
perl -i -npe 's,netaddr.IPPrefixFrom,netip.PrefixFrom,' $(git grep -l -F netaddr.)
perl -i -npe 's,netaddr.IPPortFrom,netip.AddrPortFrom,' $(git grep -l -F netaddr. )
perl -i -npe 's,netaddr.IPPrefix,netip.Prefix,g' $(git grep -l -F netaddr. )
perl -i -npe 's,netaddr.IPPort,netip.AddrPort,g' $(git grep -l -F netaddr. )
perl -i -npe 's,netaddr.IP\b,netip.Addr,g' $(git grep -l -F netaddr. )
perl -i -npe 's,netaddr.IPv6Raw\b,netip.AddrFrom16,g' $(git grep -l -F netaddr. )
goimports -w .
Then delete some stuff from the net/netaddr shim package which is no
longer neeed.
Updates #5162
Change-Id: Ia7a86893fe21c7e3ee1ec823e8aba288d4566cd8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-25 21:14:09 -07:00
|
|
|
var ipp netip.AddrPort
|
2021-03-15 17:59:35 -04:00
|
|
|
if v := r.FormValue("addr"); v != "" {
|
2024-06-14 08:05:47 -07:00
|
|
|
if strings.HasPrefix(v, "nodekey:") {
|
|
|
|
|
var k key.NodePublic
|
|
|
|
|
if err := k.UnmarshalText([]byte(v)); err != nil {
|
|
|
|
|
http.Error(w, "invalid nodekey in 'addr' parameter", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
n, u, ok = b.WhoIsNodeKey(k)
|
|
|
|
|
} else if ip, err := netip.ParseAddr(v); err == nil {
|
2023-10-10 10:39:08 -07:00
|
|
|
ipp = netip.AddrPortFrom(ip, 0)
|
|
|
|
|
} else {
|
|
|
|
|
var err error
|
|
|
|
|
ipp, err = netip.ParseAddrPort(v)
|
|
|
|
|
if err != nil {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "invalid 'addr' parameter", http.StatusBadRequest)
|
2023-10-10 10:39:08 -07:00
|
|
|
return
|
|
|
|
|
}
|
2021-02-15 10:41:52 -08:00
|
|
|
}
|
2024-06-14 08:05:47 -07:00
|
|
|
if ipp.IsValid() {
|
2024-06-06 14:48:40 -04:00
|
|
|
n, u, ok = b.WhoIs(r.FormValue("proto"), ipp)
|
2024-06-14 08:05:47 -07:00
|
|
|
}
|
2021-02-15 10:41:52 -08:00
|
|
|
} else {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "missing 'addr' parameter", http.StatusBadRequest)
|
2021-02-15 10:41:52 -08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !ok {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "no match for IP:port", http.StatusNotFound)
|
2021-02-15 10:41:52 -08:00
|
|
|
return
|
|
|
|
|
}
|
2021-04-13 08:13:46 -07:00
|
|
|
res := &apitype.WhoIsResponse{
|
2023-08-18 07:57:44 -07:00
|
|
|
Node: n.AsStruct(), // always non-nil per WhoIsResponse contract
|
|
|
|
|
UserProfile: &u, // always non-nil per WhoIsResponse contract
|
2023-10-10 10:39:08 -07:00
|
|
|
}
|
|
|
|
|
if n.Addresses().Len() > 0 {
|
|
|
|
|
res.CapMap = b.PeerCaps(n.Addresses().At(0).Addr())
|
2021-02-15 10:41:52 -08:00
|
|
|
}
|
|
|
|
|
j, err := json.MarshalIndent(res, "", "\t")
|
|
|
|
|
if err != nil {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "JSON encoding error", http.StatusInternalServerError)
|
2021-02-15 10:41:52 -08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.Write(j)
|
|
|
|
|
}
|
2021-03-05 12:07:00 -08:00
|
|
|
|
|
|
|
|
func (h *Handler) serveGoroutines(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
// Require write access out of paranoia that the goroutine dump
|
|
|
|
|
// (at least its arguments) might contain something sensitive.
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "goroutine dump access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
buf := make([]byte, 2<<20)
|
|
|
|
|
buf = buf[:runtime.Stack(buf, true)]
|
|
|
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
|
|
|
w.Write(buf)
|
|
|
|
|
}
|
2021-03-18 19:34:59 -07:00
|
|
|
|
2022-12-23 20:54:30 -08:00
|
|
|
// serveLogTap taps into the tailscaled/logtail server output and streams
|
|
|
|
|
// it to the client.
|
|
|
|
|
func (h *Handler) serveLogTap(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
|
|
|
|
|
// Require write access (~root) as the logs could contain something
|
|
|
|
|
// sensitive.
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "logtap access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.GET {
|
2022-12-23 20:54:30 -08:00
|
|
|
http.Error(w, "GET required", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
f, ok := w.(http.Flusher)
|
|
|
|
|
if !ok {
|
|
|
|
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
io.WriteString(w, `{"text":"[logtap connected]\n"}`+"\n")
|
|
|
|
|
f.Flush()
|
|
|
|
|
|
|
|
|
|
msgc := make(chan string, 16)
|
|
|
|
|
unreg := logtail.RegisterLogTap(msgc)
|
|
|
|
|
defer unreg()
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case msg := <-msgc:
|
|
|
|
|
io.WriteString(w, msg)
|
|
|
|
|
f.Flush()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-12-13 18:51:24 -08:00
|
|
|
func (h *Handler) serveMetrics(w http.ResponseWriter, r *http.Request) {
|
2024-11-26 18:13:17 +00:00
|
|
|
metricDebugMetricsCalls.Add(1)
|
2020-12-13 18:51:24 -08:00
|
|
|
// Require write access out of paranoia that the metrics
|
|
|
|
|
// might contain something sensitive.
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "metric access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
|
|
|
clientmetric.WritePrometheusExpositionFormat(w)
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-26 18:28:22 +01:00
|
|
|
// serveUserMetrics returns user-facing metrics in Prometheus text
|
|
|
|
|
// exposition format.
|
2024-08-01 13:00:36 +02:00
|
|
|
func (h *Handler) serveUserMetrics(w http.ResponseWriter, r *http.Request) {
|
2024-11-26 18:13:17 +00:00
|
|
|
metricUserMetricsCalls.Add(1)
|
2024-09-23 18:34:00 +02:00
|
|
|
h.b.UserMetricsRegistry().Handler(w, r)
|
2024-08-01 13:00:36 +02:00
|
|
|
}
|
|
|
|
|
|
2022-11-10 11:41:04 -08:00
|
|
|
// servePprofFunc is the implementation of Handler.servePprof, after auth,
|
2021-09-23 09:20:14 -07:00
|
|
|
// for platforms where we want to link it in.
|
2022-11-10 11:41:04 -08:00
|
|
|
var servePprofFunc func(http.ResponseWriter, *http.Request)
|
2021-09-23 09:20:14 -07:00
|
|
|
|
2022-11-10 11:41:04 -08:00
|
|
|
func (h *Handler) servePprof(w http.ResponseWriter, r *http.Request) {
|
2021-09-23 09:20:14 -07:00
|
|
|
// Require write access out of paranoia that the profile dump
|
|
|
|
|
// might contain something sensitive.
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "profile access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2022-11-10 11:41:04 -08:00
|
|
|
if servePprofFunc == nil {
|
2021-09-23 09:20:14 -07:00
|
|
|
http.Error(w, "not implemented on this platform", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
2022-11-10 11:41:04 -08:00
|
|
|
servePprofFunc(w, r)
|
2021-09-23 09:20:14 -07:00
|
|
|
}
|
|
|
|
|
|
2024-11-07 19:27:53 +00:00
|
|
|
// disconnectControl is the handler for local API /disconnect-control endpoint that shuts down control client, so that
|
|
|
|
|
// node no longer communicates with control. Doing this makes control consider this node inactive. This can be used
|
2025-10-07 07:34:29 -07:00
|
|
|
// before shutting down a replica of HA subnet router or app connector deployments to ensure that control tells the
|
2024-11-07 19:27:53 +00:00
|
|
|
// peers to switch over to another replica whilst still maintaining th existing peer connections.
|
|
|
|
|
func (h *Handler) disconnectControl(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if r.Method != httpm.POST {
|
|
|
|
|
http.Error(w, "use POST", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.b.DisconnectControl()
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-11 13:55:57 -07:00
|
|
|
func (h *Handler) reloadConfig(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if r.Method != httpm.POST {
|
|
|
|
|
http.Error(w, "use POST", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ok, err := h.b.ReloadConfig()
|
|
|
|
|
var res apitype.ReloadConfigResponse
|
|
|
|
|
res.Reloaded = ok
|
|
|
|
|
if err != nil {
|
|
|
|
|
res.Err = err.Error()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(&res)
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-27 15:41:03 -08:00
|
|
|
func (h *Handler) serveResetAuth(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "reset-auth modify access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if r.Method != httpm.POST {
|
|
|
|
|
http.Error(w, "use POST", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := h.b.ResetAuth(); err != nil {
|
|
|
|
|
http.Error(w, "reset-auth failed: "+err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
2021-03-31 11:55:21 -07:00
|
|
|
func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitRead {
|
|
|
|
|
http.Error(w, "IP forwarding check access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var warning string
|
|
|
|
|
if err := h.b.CheckIPForwarding(); err != nil {
|
|
|
|
|
warning = err.Error()
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(struct {
|
|
|
|
|
Warning string
|
|
|
|
|
}{
|
|
|
|
|
Warning: warning,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-22 20:12:59 +01:00
|
|
|
func (h *Handler) serveCheckReversePathFiltering(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitRead {
|
|
|
|
|
http.Error(w, "reverse path filtering check access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var warning string
|
|
|
|
|
|
|
|
|
|
state := h.b.Sys().NetMon.Get().InterfaceState()
|
|
|
|
|
warn, err := netutil.CheckReversePathFiltering(state)
|
|
|
|
|
if err == nil && len(warn) > 0 {
|
|
|
|
|
var msg strings.Builder
|
|
|
|
|
msg.WriteString(healthmsg.WarnExitNodeUsage + ":\n")
|
|
|
|
|
for _, w := range warn {
|
|
|
|
|
msg.WriteString("- " + w + "\n")
|
|
|
|
|
}
|
|
|
|
|
msg.WriteString(healthmsg.DisableRPFilter)
|
|
|
|
|
warning = msg.String()
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(struct {
|
|
|
|
|
Warning string
|
|
|
|
|
}{
|
|
|
|
|
Warning: warning,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2023-11-09 11:34:41 -08:00
|
|
|
func (h *Handler) serveCheckUDPGROForwarding(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitRead {
|
|
|
|
|
http.Error(w, "UDP GRO forwarding check access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var warning string
|
|
|
|
|
if err := h.b.CheckUDPGROForwarding(); err != nil {
|
|
|
|
|
warning = err.Error()
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(struct {
|
|
|
|
|
Warning string
|
|
|
|
|
}{
|
|
|
|
|
Warning: warning,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-10 19:19:03 +01:00
|
|
|
func (h *Handler) serveSetUDPGROForwarding(w http.ResponseWriter, r *http.Request) {
|
2025-09-30 09:53:55 -07:00
|
|
|
if !buildfeatures.HasGRO {
|
|
|
|
|
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
|
|
|
|
return
|
|
|
|
|
}
|
2024-06-10 19:19:03 +01:00
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "UDP GRO forwarding set access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var warning string
|
|
|
|
|
if err := h.b.SetUDPGROForwarding(); err != nil {
|
|
|
|
|
warning = err.Error()
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(struct {
|
|
|
|
|
Warning string
|
|
|
|
|
}{
|
|
|
|
|
Warning: warning,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2021-03-18 19:34:59 -07:00
|
|
|
func (h *Handler) serveStatus(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitRead {
|
|
|
|
|
http.Error(w, "status access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
2021-03-18 21:07:58 -07:00
|
|
|
var st *ipnstate.Status
|
|
|
|
|
if defBool(r.FormValue("peers"), true) {
|
|
|
|
|
st = h.b.Status()
|
|
|
|
|
} else {
|
|
|
|
|
st = h.b.StatusWithoutPeers()
|
|
|
|
|
}
|
2021-03-18 19:34:59 -07:00
|
|
|
e := json.NewEncoder(w)
|
|
|
|
|
e.SetIndent("", "\t")
|
2021-03-18 21:07:58 -07:00
|
|
|
e.Encode(st)
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-01 14:39:03 -08:00
|
|
|
// InUseOtherUserIPNStream reports whether r is a request for the watch-ipn-bus
|
|
|
|
|
// handler. If so, it writes an ipn.Notify InUseOtherUser message to the user
|
|
|
|
|
// and returns true. Otherwise it returns false, in which case it doesn't write
|
|
|
|
|
// to w.
|
|
|
|
|
//
|
|
|
|
|
// Unlike the regular watch-ipn-bus handler, this one doesn't block. The caller
|
|
|
|
|
// (in ipnserver.Server) provides the blocking until the connection is no longer
|
|
|
|
|
// in use.
|
|
|
|
|
func InUseOtherUserIPNStream(w http.ResponseWriter, r *http.Request, err error) (handled bool) {
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.GET || r.URL.Path != "/localapi/v0/watch-ipn-bus" {
|
2022-12-01 14:39:03 -08:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
js, err := json.Marshal(&ipn.Notify{
|
2023-02-10 22:20:36 -08:00
|
|
|
Version: version.Long(),
|
2022-12-01 14:39:03 -08:00
|
|
|
State: ptr.To(ipn.InUseOtherUser),
|
|
|
|
|
ErrMessage: ptr.To(err.Error()),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
js = append(js, '\n')
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.Write(js)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-22 11:41:03 -08:00
|
|
|
func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) {
|
2023-06-04 16:05:21 +00:00
|
|
|
if !h.PermitRead {
|
|
|
|
|
http.Error(w, "watch ipn bus access denied", http.StatusForbidden)
|
2022-11-22 11:41:03 -08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
f, ok := w.(http.Flusher)
|
|
|
|
|
if !ok {
|
|
|
|
|
http.Error(w, "not a flusher", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-26 12:19:16 -08:00
|
|
|
var mask ipn.NotifyWatchOpt
|
|
|
|
|
if s := r.FormValue("mask"); s != "" {
|
|
|
|
|
v, err := strconv.ParseUint(s, 10, 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "bad mask", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
mask = ipn.NotifyWatchOpt(v)
|
|
|
|
|
}
|
2023-11-02 10:48:10 -06:00
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
2022-11-22 11:41:03 -08:00
|
|
|
ctx := r.Context()
|
2024-03-20 16:05:46 -07:00
|
|
|
enc := json.NewEncoder(w)
|
ipn/{ipnauth,ipnlocal,ipnserver}: send the auth URL to the user who started interactive login
We add the ClientID() method to the ipnauth.Actor interface and updated ipnserver.actor to implement it.
This method returns a unique ID of the connected client if the actor represents one. It helps link a series
of interactions initiated by the client, such as when a notification needs to be sent back to a specific session,
rather than all active sessions, in response to a certain request.
We also add LocalBackend.WatchNotificationsAs and LocalBackend.StartLoginInteractiveAs methods,
which are like WatchNotifications and StartLoginInteractive but accept an additional parameter
specifying an ipnauth.Actor who initiates the operation. We store these actor identities in
watchSession.owner and LocalBackend.authActor, respectively,and implement LocalBackend.sendTo
and related helper methods to enable sending notifications to watchSessions associated with actors
(or, more broadly, identifiable recipients).
We then use the above to change who receives the BrowseToURL notifications:
- For user-initiated, interactive logins, the notification is delivered only to the user who initiated the
process. If the initiating actor represents a specific connected client, the URL notification is sent back
to the same LocalAPI client that called StartLoginInteractive. Otherwise, the notification is sent to all
clients connected as that user.
Currently, we only differentiate between users on Windows, as it is inherently a multi-user OS.
- In all other cases (e.g., node key expiration), we send the notification to all connected users.
Updates tailscale/corp#18342
Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-10-13 11:36:46 -05:00
|
|
|
h.b.WatchNotificationsAs(ctx, h.Actor, mask, f.Flush, func(roNotify *ipn.Notify) (keepGoing bool) {
|
2024-03-20 16:05:46 -07:00
|
|
|
err := enc.Encode(roNotify)
|
2022-11-22 11:41:03 -08:00
|
|
|
if err != nil {
|
2024-03-20 16:05:46 -07:00
|
|
|
h.logf("json.Encode: %v", err)
|
2022-11-22 11:41:03 -08:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
f.Flush()
|
|
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2022-05-26 13:08:27 -05:00
|
|
|
func (h *Handler) serveLoginInteractive(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "login access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.POST {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "want POST", http.StatusBadRequest)
|
2022-05-26 13:08:27 -05:00
|
|
|
return
|
|
|
|
|
}
|
ipn/{ipnauth,ipnlocal,ipnserver}: send the auth URL to the user who started interactive login
We add the ClientID() method to the ipnauth.Actor interface and updated ipnserver.actor to implement it.
This method returns a unique ID of the connected client if the actor represents one. It helps link a series
of interactions initiated by the client, such as when a notification needs to be sent back to a specific session,
rather than all active sessions, in response to a certain request.
We also add LocalBackend.WatchNotificationsAs and LocalBackend.StartLoginInteractiveAs methods,
which are like WatchNotifications and StartLoginInteractive but accept an additional parameter
specifying an ipnauth.Actor who initiates the operation. We store these actor identities in
watchSession.owner and LocalBackend.authActor, respectively,and implement LocalBackend.sendTo
and related helper methods to enable sending notifications to watchSessions associated with actors
(or, more broadly, identifiable recipients).
We then use the above to change who receives the BrowseToURL notifications:
- For user-initiated, interactive logins, the notification is delivered only to the user who initiated the
process. If the initiating actor represents a specific connected client, the URL notification is sent back
to the same LocalAPI client that called StartLoginInteractive. Otherwise, the notification is sent to all
clients connected as that user.
Currently, we only differentiate between users on Windows, as it is inherently a multi-user OS.
- In all other cases (e.g., node key expiration), we send the notification to all connected users.
Updates tailscale/corp#18342
Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-10-13 11:36:46 -05:00
|
|
|
h.b.StartLoginInteractiveAs(r.Context(), h.Actor)
|
2022-05-26 13:08:27 -05:00
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-22 11:41:03 -08:00
|
|
|
func (h *Handler) serveStart(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.POST {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "want POST", http.StatusBadRequest)
|
2022-11-22 11:41:03 -08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var o ipn.Options
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&o); err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
err := h.b.Start(o)
|
|
|
|
|
if err != nil {
|
|
|
|
|
// TODO(bradfitz): map error to a good HTTP error
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-07 21:06:31 -07:00
|
|
|
func (h *Handler) serveLogout(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "logout access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.POST {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "want POST", http.StatusBadRequest)
|
2021-04-07 21:06:31 -07:00
|
|
|
return
|
|
|
|
|
}
|
2025-07-08 14:37:13 -05:00
|
|
|
err := h.b.Logout(r.Context(), h.Actor)
|
2021-04-07 21:06:31 -07:00
|
|
|
if err == nil {
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
return
|
|
|
|
|
}
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
2021-04-07 21:06:31 -07:00
|
|
|
}
|
|
|
|
|
|
2021-04-07 08:27:35 -07:00
|
|
|
func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitRead {
|
|
|
|
|
http.Error(w, "prefs access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2022-10-23 17:07:10 +00:00
|
|
|
var prefs ipn.PrefsView
|
2021-04-11 20:49:07 -07:00
|
|
|
switch r.Method {
|
2025-06-11 14:22:30 -04:00
|
|
|
case httpm.PATCH:
|
2021-04-11 21:31:15 -07:00
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "prefs write access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2021-04-11 16:10:31 -07:00
|
|
|
mp := new(ipn.MaskedPrefs)
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(mp); err != nil {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
2021-04-11 16:10:31 -07:00
|
|
|
return
|
|
|
|
|
}
|
2025-09-30 13:11:48 -07:00
|
|
|
if buildfeatures.HasAppConnectors {
|
|
|
|
|
if err := h.b.MaybeClearAppConnector(mp); err != nil {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
|
json.NewEncoder(w).Encode(resJSON{Error: err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
2024-04-15 11:30:00 -07:00
|
|
|
}
|
2021-04-11 16:10:31 -07:00
|
|
|
var err error
|
ipn/ipn{auth,server,local}: initial support for the always-on mode
In this PR, we update LocalBackend to set WantRunning=true when applying policy settings
to the current profile's prefs, if the "always-on" mode is enabled.
We also implement a new (*LocalBackend).EditPrefsAs() method, which is like EditPrefs
but accepts an actor (e.g., a LocalAPI client's identity) that initiated the change.
If WantRunning is being set to false, the new EditPrefsAs method checks whether the actor
has ipnauth.Disconnect access to the profile and propagates an error if they do not.
Finally, we update (*ipnserver.actor).CheckProfileAccess to allow a disconnect
only if the "always-on" mode is not enabled by the AlwaysOn policy setting.
This is not a comprehensive solution to the "always-on" mode across platforms,
as instead of disconnecting a user could achieve the same effect by creating
a new empty profile, initiating a reauth, or by deleting the profile.
These are the things we should address in future PRs.
Updates #14823
Signed-off-by: Nick Khyl <nickk@tailscale.com>
2025-01-30 18:29:02 -06:00
|
|
|
prefs, err = h.b.EditPrefsAs(mp, h.Actor)
|
2021-04-11 16:10:31 -07:00
|
|
|
if err != nil {
|
2022-04-18 09:37:23 -07:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
json.NewEncoder(w).Encode(resJSON{Error: err.Error()})
|
2021-04-11 16:10:31 -07:00
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
case httpm.GET, httpm.HEAD:
|
2022-10-24 00:15:04 +00:00
|
|
|
prefs = h.b.Prefs()
|
2021-04-11 20:49:07 -07:00
|
|
|
default:
|
|
|
|
|
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
2021-04-11 16:10:31 -07:00
|
|
|
}
|
2021-04-07 08:27:35 -07:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
e := json.NewEncoder(w)
|
|
|
|
|
e.SetIndent("", "\t")
|
2021-04-11 16:10:31 -07:00
|
|
|
e.Encode(prefs)
|
2021-04-07 08:27:35 -07:00
|
|
|
}
|
|
|
|
|
|
2022-04-18 09:37:23 -07:00
|
|
|
type resJSON struct {
|
|
|
|
|
Error string `json:",omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) serveCheckPrefs(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "checkprefs access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.POST {
|
2022-04-18 09:37:23 -07:00
|
|
|
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
p := new(ipn.Prefs)
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(p); err != nil {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
2022-04-18 09:37:23 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
err := h.b.CheckPrefs(p)
|
|
|
|
|
var res resJSON
|
|
|
|
|
if err != nil {
|
|
|
|
|
res.Error = err.Error()
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(res)
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-15 08:28:48 -07:00
|
|
|
// WriteErrorJSON writes a JSON object (with a single "error" string field) to w
|
|
|
|
|
// with the given error. If err is nil, "unexpected nil error" is used for the
|
|
|
|
|
// stringification instead.
|
|
|
|
|
func WriteErrorJSON(w http.ResponseWriter, err error) {
|
2021-04-16 10:57:46 -07:00
|
|
|
if err == nil {
|
|
|
|
|
err = errors.New("unexpected nil error")
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
2023-10-13 17:40:10 -05:00
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
2021-04-16 10:57:46 -07:00
|
|
|
type E struct {
|
|
|
|
|
Error string `json:"error"`
|
|
|
|
|
}
|
|
|
|
|
json.NewEncoder(w).Encode(E{err.Error()})
|
|
|
|
|
}
|
|
|
|
|
|
2021-06-07 16:03:16 -07:00
|
|
|
func (h *Handler) serveSetDNS(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.POST {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "want POST", http.StatusBadRequest)
|
2021-06-07 16:03:16 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
err := h.b.SetDNS(ctx, r.FormValue("name"), r.FormValue("value"))
|
|
|
|
|
if err != nil {
|
2025-04-15 08:28:48 -07:00
|
|
|
WriteErrorJSON(w, err)
|
2021-06-07 16:03:16 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(struct{}{})
|
|
|
|
|
}
|
|
|
|
|
|
2021-06-25 11:44:40 -07:00
|
|
|
func (h *Handler) serveDERPMap(w http.ResponseWriter, r *http.Request) {
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.GET {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "want GET", http.StatusBadRequest)
|
2021-06-25 11:44:40 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
e := json.NewEncoder(w)
|
|
|
|
|
e.SetIndent("", "\t")
|
|
|
|
|
e.Encode(h.b.DERPMap())
|
|
|
|
|
}
|
|
|
|
|
|
2022-03-09 14:42:42 -08:00
|
|
|
// serveSetExpirySooner sets the expiry date on the current machine, specified
|
|
|
|
|
// by an `expiry` unix timestamp as POST or query param.
|
|
|
|
|
func (h *Handler) serveSetExpirySooner(w http.ResponseWriter, r *http.Request) {
|
2023-01-23 14:55:36 -08:00
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.POST {
|
2022-03-09 14:42:42 -08:00
|
|
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var expiryTime time.Time
|
|
|
|
|
if v := r.FormValue("expiry"); v != "" {
|
|
|
|
|
expiryInt, err := strconv.ParseInt(v, 10, 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "can't parse expiry time, expects a unix timestamp", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
expiryTime = time.Unix(expiryInt, 0)
|
|
|
|
|
} else {
|
|
|
|
|
http.Error(w, "missing 'expiry' parameter, a unix timestamp", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
err := h.b.SetExpirySooner(r.Context(), expiryTime)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
|
|
|
io.WriteString(w, "done\n")
|
|
|
|
|
}
|
|
|
|
|
|
2022-05-03 14:16:34 -07:00
|
|
|
func (h *Handler) servePing(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ctx := r.Context()
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.POST {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "want POST", http.StatusBadRequest)
|
2022-05-03 14:16:34 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ipStr := r.FormValue("ip")
|
|
|
|
|
if ipStr == "" {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "missing 'ip' parameter", http.StatusBadRequest)
|
2022-05-03 14:16:34 -07:00
|
|
|
return
|
|
|
|
|
}
|
2022-07-25 20:55:44 -07:00
|
|
|
ip, err := netip.ParseAddr(ipStr)
|
2022-05-03 14:16:34 -07:00
|
|
|
if err != nil {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "invalid IP", http.StatusBadRequest)
|
2022-05-03 14:16:34 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
pingTypeStr := r.FormValue("type")
|
2023-06-27 09:33:29 -07:00
|
|
|
if pingTypeStr == "" {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "missing 'type' parameter", http.StatusBadRequest)
|
2022-05-03 14:16:34 -07:00
|
|
|
return
|
|
|
|
|
}
|
2023-08-08 13:11:28 +01:00
|
|
|
size := 0
|
|
|
|
|
sizeStr := r.FormValue("size")
|
|
|
|
|
if sizeStr != "" {
|
|
|
|
|
size, err = strconv.Atoi(sizeStr)
|
|
|
|
|
if err != nil {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "invalid 'size' parameter", http.StatusBadRequest)
|
2023-08-08 13:11:28 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if size != 0 && tailcfg.PingType(pingTypeStr) != tailcfg.PingDisco {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "'size' parameter is only supported with disco pings", http.StatusBadRequest)
|
2023-08-08 13:11:28 +01:00
|
|
|
return
|
|
|
|
|
}
|
2023-09-22 17:49:09 +02:00
|
|
|
if size > magicsock.MaxDiscoPingSize {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, fmt.Sprintf("maximum value for 'size' is %v", magicsock.MaxDiscoPingSize), http.StatusBadRequest)
|
2023-08-08 13:11:28 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
res, err := h.b.Ping(ctx, ip, tailcfg.PingType(pingTypeStr), size)
|
2022-05-03 14:16:34 -07:00
|
|
|
if err != nil {
|
2025-04-15 08:28:48 -07:00
|
|
|
WriteErrorJSON(w, err)
|
2022-05-03 14:16:34 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(res)
|
|
|
|
|
}
|
|
|
|
|
|
2022-03-24 09:04:01 -07:00
|
|
|
func (h *Handler) serveDial(w http.ResponseWriter, r *http.Request) {
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.POST {
|
2022-03-24 09:04:01 -07:00
|
|
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const upgradeProto = "ts-dial"
|
|
|
|
|
if !strings.Contains(r.Header.Get("Connection"), "upgrade") ||
|
|
|
|
|
r.Header.Get("Upgrade") != upgradeProto {
|
|
|
|
|
http.Error(w, "bad ts-dial upgrade", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
hostStr, portStr := r.Header.Get("Dial-Host"), r.Header.Get("Dial-Port")
|
|
|
|
|
if hostStr == "" || portStr == "" {
|
|
|
|
|
http.Error(w, "missing Dial-Host or Dial-Port header", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
hijacker, ok := w.(http.Hijacker)
|
|
|
|
|
if !ok {
|
|
|
|
|
http.Error(w, "make request over HTTP/1", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-18 14:37:37 -07:00
|
|
|
network := cmp.Or(r.Header.Get("Dial-Network"), "tcp")
|
|
|
|
|
|
2022-03-24 09:04:01 -07:00
|
|
|
addr := net.JoinHostPort(hostStr, portStr)
|
2024-05-18 14:37:37 -07:00
|
|
|
outConn, err := h.b.Dialer().UserDial(r.Context(), network, addr)
|
2022-03-24 09:04:01 -07:00
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "dial failure: "+err.Error(), http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer outConn.Close()
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Upgrade", upgradeProto)
|
|
|
|
|
w.Header().Set("Connection", "upgrade")
|
|
|
|
|
w.WriteHeader(http.StatusSwitchingProtocols)
|
|
|
|
|
|
|
|
|
|
reqConn, brw, err := hijacker.Hijack()
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.logf("localapi dial Hijack error: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer reqConn.Close()
|
|
|
|
|
if err := brw.Flush(); err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
reqConn = netutil.NewDrainBufConn(reqConn, brw.Reader)
|
|
|
|
|
|
|
|
|
|
errc := make(chan error, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
_, err := io.Copy(reqConn, outConn)
|
|
|
|
|
errc <- err
|
|
|
|
|
}()
|
|
|
|
|
go func() {
|
|
|
|
|
_, err := io.Copy(outConn, reqConn)
|
|
|
|
|
errc <- err
|
|
|
|
|
}()
|
|
|
|
|
<-errc
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-29 14:04:40 -08:00
|
|
|
func (h *Handler) serveSetPushDeviceToken(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "set push device token access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.POST {
|
2023-01-29 14:04:40 -08:00
|
|
|
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var params apitype.SetPushDeviceTokenRequest
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
2023-01-29 14:04:40 -08:00
|
|
|
return
|
|
|
|
|
}
|
2023-10-23 10:22:34 -07:00
|
|
|
h.b.SetPushDeviceToken(params.PushDeviceToken)
|
2023-01-29 14:04:40 -08:00
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-30 18:35:53 -07:00
|
|
|
func (h *Handler) serveHandlePushMessage(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "handle push message not allowed", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.POST {
|
2023-10-30 18:35:53 -07:00
|
|
|
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var pushMessageBody map[string]any
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&pushMessageBody); err != nil {
|
|
|
|
|
http.Error(w, "failed to decode JSON body: "+err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TODO(bradfitz): do something with pushMessageBody
|
|
|
|
|
h.logf("localapi: got push message: %v", logger.AsJSON(pushMessageBody))
|
|
|
|
|
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
2022-07-08 11:57:34 -07:00
|
|
|
func (h *Handler) serveUploadClientMetrics(w http.ResponseWriter, r *http.Request) {
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.POST {
|
2022-07-08 11:57:34 -07:00
|
|
|
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
type clientMetricJSON struct {
|
2023-08-14 18:01:43 -04:00
|
|
|
Name string `json:"name"`
|
|
|
|
|
Type string `json:"type"` // one of "counter" or "gauge"
|
|
|
|
|
Value int `json:"value"` // amount to increment metric by
|
2022-07-08 11:57:34 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var clientMetrics []clientMetricJSON
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&clientMetrics); err != nil {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
2022-07-08 11:57:34 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
metricsMu.Lock()
|
|
|
|
|
defer metricsMu.Unlock()
|
|
|
|
|
|
|
|
|
|
for _, m := range clientMetrics {
|
|
|
|
|
if metric, ok := metrics[m.Name]; ok {
|
|
|
|
|
metric.Add(int64(m.Value))
|
|
|
|
|
} else {
|
|
|
|
|
if clientmetric.HasPublished(m.Name) {
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "Already have a metric named "+m.Name, http.StatusBadRequest)
|
2022-07-08 11:57:34 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var metric *clientmetric.Metric
|
|
|
|
|
switch m.Type {
|
|
|
|
|
case "counter":
|
|
|
|
|
metric = clientmetric.NewCounter(m.Name)
|
|
|
|
|
case "gauge":
|
|
|
|
|
metric = clientmetric.NewGauge(m.Name)
|
|
|
|
|
default:
|
2023-10-13 17:40:10 -05:00
|
|
|
http.Error(w, "Unknown metric type "+m.Type, http.StatusBadRequest)
|
2022-07-08 11:57:34 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
metrics[m.Name] = metric
|
|
|
|
|
metric.Add(int64(m.Value))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(struct{}{})
|
|
|
|
|
}
|
|
|
|
|
|
2024-02-29 09:36:35 -08:00
|
|
|
func (h *Handler) serveSetGUIVisible(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != httpm.POST {
|
|
|
|
|
http.Error(w, "use POST", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type setGUIVisibleRequest struct {
|
|
|
|
|
IsVisible bool // whether the Tailscale client UI is now presented to the user
|
|
|
|
|
SessionID string // the last SessionID sent to the client in ipn.Notify.SessionID
|
|
|
|
|
}
|
|
|
|
|
var req setGUIVisibleRequest
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TODO(bradfitz): use `req.IsVisible == true` to flush netmap
|
|
|
|
|
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-03 10:51:51 -07:00
|
|
|
func (h *Handler) serveSetUseExitNodeEnabled(w http.ResponseWriter, r *http.Request) {
|
2025-10-01 19:18:46 -07:00
|
|
|
if !buildfeatures.HasUseExitNode {
|
|
|
|
|
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
|
|
|
|
return
|
|
|
|
|
}
|
2024-04-03 10:51:51 -07:00
|
|
|
if r.Method != httpm.POST {
|
|
|
|
|
http.Error(w, "use POST", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
v, err := strconv.ParseBool(r.URL.Query().Get("enabled"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "invalid 'enabled' parameter", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-07-07 17:04:07 -05:00
|
|
|
prefs, err := h.b.SetUseExitNodeEnabled(h.Actor, v)
|
2024-04-03 10:51:51 -07:00
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
e := json.NewEncoder(w)
|
|
|
|
|
e.SetIndent("", "\t")
|
|
|
|
|
e.Encode(prefs)
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-10 13:38:22 -08:00
|
|
|
// serveProfiles serves profile switching-related endpoints. Supported methods
|
|
|
|
|
// and paths are:
|
|
|
|
|
// - GET /profiles/: list all profiles (JSON-encoded array of ipn.LoginProfiles)
|
|
|
|
|
// - PUT /profiles/: add new profile (no response). A separate
|
|
|
|
|
// StartLoginInteractive() is needed to populate and persist the new profile.
|
|
|
|
|
// - GET /profiles/current: current profile (JSON-ecoded ipn.LoginProfile)
|
|
|
|
|
// - GET /profiles/<id>: output profile (JSON-ecoded ipn.LoginProfile)
|
|
|
|
|
// - POST /profiles/<id>: switch to profile (no response)
|
|
|
|
|
// - DELETE /profiles/<id>: delete profile (no response)
|
|
|
|
|
func (h *Handler) serveProfiles(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "profiles access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2023-02-01 13:43:06 -08:00
|
|
|
suffix, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/profiles/")
|
2022-11-10 13:38:22 -08:00
|
|
|
if !ok {
|
2022-11-18 10:13:14 -08:00
|
|
|
http.Error(w, "misconfigured", http.StatusInternalServerError)
|
|
|
|
|
return
|
2022-11-10 13:38:22 -08:00
|
|
|
}
|
|
|
|
|
if suffix == "" {
|
|
|
|
|
switch r.Method {
|
2023-01-26 19:35:26 -08:00
|
|
|
case httpm.GET:
|
2022-11-10 13:38:22 -08:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(h.b.ListProfiles())
|
2023-01-26 19:35:26 -08:00
|
|
|
case httpm.PUT:
|
2022-11-10 13:38:22 -08:00
|
|
|
err := h.b.NewProfile()
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
|
|
|
default:
|
|
|
|
|
http.Error(w, "use GET or PUT", http.StatusMethodNotAllowed)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
suffix, err := url.PathUnescape(suffix)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "bad profile ID", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if suffix == "current" {
|
|
|
|
|
switch r.Method {
|
2023-01-26 19:35:26 -08:00
|
|
|
case httpm.GET:
|
2022-11-10 13:38:22 -08:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(h.b.CurrentProfile())
|
|
|
|
|
default:
|
|
|
|
|
http.Error(w, "use GET", http.StatusMethodNotAllowed)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
profileID := ipn.ProfileID(suffix)
|
|
|
|
|
switch r.Method {
|
2023-01-26 19:35:26 -08:00
|
|
|
case httpm.GET:
|
2022-11-10 13:38:22 -08:00
|
|
|
profiles := h.b.ListProfiles()
|
2025-01-30 11:24:25 -06:00
|
|
|
profileIndex := slices.IndexFunc(profiles, func(p ipn.LoginProfileView) bool {
|
|
|
|
|
return p.ID() == profileID
|
2022-11-10 13:38:22 -08:00
|
|
|
})
|
|
|
|
|
if profileIndex == -1 {
|
|
|
|
|
http.Error(w, "Profile not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(profiles[profileIndex])
|
2023-01-26 19:35:26 -08:00
|
|
|
case httpm.POST:
|
2022-11-10 13:38:22 -08:00
|
|
|
err := h.b.SwitchProfile(profileID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
2023-01-26 19:35:26 -08:00
|
|
|
case httpm.DELETE:
|
2022-11-10 13:38:22 -08:00
|
|
|
err := h.b.DeleteProfile(profileID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
default:
|
|
|
|
|
http.Error(w, "use POST or DELETE", http.StatusMethodNotAllowed)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-02 10:56:18 -04:00
|
|
|
// serveQueryFeature makes a request to the "/machine/feature/query"
|
|
|
|
|
// Noise endpoint to get instructions on how to enable a feature, such as
|
|
|
|
|
// Funnel, for the node's tailnet.
|
|
|
|
|
//
|
|
|
|
|
// This request itself does not directly enable the feature on behalf of
|
|
|
|
|
// the node, but rather returns information that can be presented to the
|
|
|
|
|
// acting user about where/how to enable the feature. If relevant, this
|
|
|
|
|
// includes a control URL the user can visit to explicitly consent to
|
|
|
|
|
// using the feature.
|
|
|
|
|
//
|
|
|
|
|
// See tailcfg.QueryFeatureResponse for full response structure.
|
|
|
|
|
func (h *Handler) serveQueryFeature(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
feature := r.FormValue("feature")
|
|
|
|
|
switch {
|
|
|
|
|
case !h.PermitRead:
|
|
|
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
case r.Method != httpm.POST:
|
|
|
|
|
http.Error(w, "use POST", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
case feature == "":
|
|
|
|
|
http.Error(w, "missing feature", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
nm := h.b.NetMap()
|
|
|
|
|
if nm == nil {
|
|
|
|
|
http.Error(w, "no netmap", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
b, err := json.Marshal(&tailcfg.QueryFeatureRequest{
|
|
|
|
|
NodeKey: nm.NodeKey,
|
|
|
|
|
Feature: feature,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(),
|
2025-06-11 14:22:30 -04:00
|
|
|
httpm.POST, "https://unused/machine/feature/query", bytes.NewReader(b))
|
2023-08-02 10:56:18 -04:00
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := h.b.DoNoiseRequest(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(resp.StatusCode)
|
|
|
|
|
if _, err := io.Copy(w, resp.Body); err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-03-18 21:07:58 -07:00
|
|
|
func defBool(a string, def bool) bool {
|
|
|
|
|
if a == "" {
|
|
|
|
|
return def
|
|
|
|
|
}
|
|
|
|
|
v, err := strconv.ParseBool(a)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return def
|
|
|
|
|
}
|
|
|
|
|
return v
|
2021-03-18 19:34:59 -07:00
|
|
|
}
|
2022-12-09 14:21:53 -08:00
|
|
|
|
2023-11-09 13:00:47 -08:00
|
|
|
// serveUpdateCheck returns the ClientVersion from Status, which contains
|
|
|
|
|
// information on whether an update is available, and if so, what version,
|
|
|
|
|
// *if* we support auto-updates on this platform. If we don't, this endpoint
|
|
|
|
|
// always returns a ClientVersion saying we're running the newest version.
|
|
|
|
|
// Effectively, it tells us whether serveUpdateInstall will be able to install
|
|
|
|
|
// an update for us.
|
|
|
|
|
func (h *Handler) serveUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.GET {
|
2023-11-09 13:00:47 -08:00
|
|
|
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
cv := h.b.StatusWithoutPeers().ClientVersion
|
|
|
|
|
// ipnstate.Status documentation notes that ClientVersion may be nil on some
|
|
|
|
|
// platforms where this information is unavailable. In that case, return a
|
|
|
|
|
// ClientVersion that says we're up to date, since we have no information on
|
|
|
|
|
// whether an update is possible.
|
|
|
|
|
if cv == nil {
|
|
|
|
|
cv = &tailcfg.ClientVersion{RunningLatest: true}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
json.NewEncoder(w).Encode(cv)
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-04 12:43:55 -07:00
|
|
|
// serveDNSOSConfig serves the current system DNS configuration as a JSON object, if
|
|
|
|
|
// supported by the OS.
|
|
|
|
|
func (h *Handler) serveDNSOSConfig(w http.ResponseWriter, r *http.Request) {
|
2025-09-29 22:10:28 -07:00
|
|
|
if !buildfeatures.HasDNS {
|
2025-09-30 09:53:55 -07:00
|
|
|
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
2025-09-29 22:10:28 -07:00
|
|
|
return
|
|
|
|
|
}
|
2024-09-04 12:43:55 -07:00
|
|
|
if r.Method != httpm.GET {
|
|
|
|
|
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// Require write access for privacy reasons.
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "dns-osconfig dump access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
bCfg, err := h.b.GetDNSOSConfig()
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
nameservers := make([]string, 0, len(bCfg.Nameservers))
|
|
|
|
|
for _, ns := range bCfg.Nameservers {
|
|
|
|
|
nameservers = append(nameservers, ns.String())
|
|
|
|
|
}
|
|
|
|
|
searchDomains := make([]string, 0, len(bCfg.SearchDomains))
|
|
|
|
|
for _, sd := range bCfg.SearchDomains {
|
|
|
|
|
searchDomains = append(searchDomains, sd.WithoutTrailingDot())
|
|
|
|
|
}
|
|
|
|
|
matchDomains := make([]string, 0, len(bCfg.MatchDomains))
|
|
|
|
|
for _, md := range bCfg.MatchDomains {
|
|
|
|
|
matchDomains = append(matchDomains, md.WithoutTrailingDot())
|
|
|
|
|
}
|
|
|
|
|
response := apitype.DNSOSConfig{
|
|
|
|
|
Nameservers: nameservers,
|
|
|
|
|
SearchDomains: searchDomains,
|
|
|
|
|
MatchDomains: matchDomains,
|
|
|
|
|
}
|
|
|
|
|
json.NewEncoder(w).Encode(response)
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-24 13:18:45 -07:00
|
|
|
// serveDNSQuery provides the ability to perform DNS queries using the internal
|
|
|
|
|
// DNS forwarder. This is useful for debugging and testing purposes.
|
|
|
|
|
// URL parameters:
|
|
|
|
|
// - name: the domain name to query
|
|
|
|
|
// - type: the DNS record type to query as a number (default if empty: A = '1')
|
|
|
|
|
//
|
|
|
|
|
// The response if successful is a DNSQueryResponse JSON object.
|
|
|
|
|
func (h *Handler) serveDNSQuery(w http.ResponseWriter, r *http.Request) {
|
2025-09-29 22:10:28 -07:00
|
|
|
if !buildfeatures.HasDNS {
|
2025-09-30 09:53:55 -07:00
|
|
|
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
2025-09-29 22:10:28 -07:00
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.GET {
|
2024-09-24 13:18:45 -07:00
|
|
|
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// Require write access for privacy reasons.
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "dns-query access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
q := r.URL.Query()
|
|
|
|
|
name := q.Get("name")
|
|
|
|
|
queryType := q.Get("type")
|
|
|
|
|
qt := dnsmessage.TypeA
|
|
|
|
|
if queryType != "" {
|
2025-09-26 21:17:07 -07:00
|
|
|
t, err := dnsMessageTypeForString(queryType)
|
2024-09-24 13:18:45 -07:00
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
qt = t
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res, rrs, err := h.b.QueryDNS(name, qt)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(&apitype.DNSQueryResponse{
|
|
|
|
|
Bytes: res,
|
|
|
|
|
Resolvers: rrs,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-26 21:17:07 -07:00
|
|
|
// dnsMessageTypeForString returns the dnsmessage.Type for the given string.
|
|
|
|
|
// For example, DNSMessageTypeForString("A") returns dnsmessage.TypeA.
|
|
|
|
|
func dnsMessageTypeForString(s string) (t dnsmessage.Type, err error) {
|
|
|
|
|
s = strings.TrimSpace(strings.ToUpper(s))
|
|
|
|
|
switch s {
|
|
|
|
|
case "AAAA":
|
|
|
|
|
return dnsmessage.TypeAAAA, nil
|
|
|
|
|
case "ALL":
|
|
|
|
|
return dnsmessage.TypeALL, nil
|
|
|
|
|
case "A":
|
|
|
|
|
return dnsmessage.TypeA, nil
|
|
|
|
|
case "CNAME":
|
|
|
|
|
return dnsmessage.TypeCNAME, nil
|
|
|
|
|
case "HINFO":
|
|
|
|
|
return dnsmessage.TypeHINFO, nil
|
|
|
|
|
case "MINFO":
|
|
|
|
|
return dnsmessage.TypeMINFO, nil
|
|
|
|
|
case "MX":
|
|
|
|
|
return dnsmessage.TypeMX, nil
|
|
|
|
|
case "NS":
|
|
|
|
|
return dnsmessage.TypeNS, nil
|
|
|
|
|
case "OPT":
|
|
|
|
|
return dnsmessage.TypeOPT, nil
|
|
|
|
|
case "PTR":
|
|
|
|
|
return dnsmessage.TypePTR, nil
|
|
|
|
|
case "SOA":
|
|
|
|
|
return dnsmessage.TypeSOA, nil
|
|
|
|
|
case "SRV":
|
|
|
|
|
return dnsmessage.TypeSRV, nil
|
|
|
|
|
case "TXT":
|
|
|
|
|
return dnsmessage.TypeTXT, nil
|
|
|
|
|
case "WKS":
|
|
|
|
|
return dnsmessage.TypeWKS, nil
|
|
|
|
|
}
|
|
|
|
|
return 0, errors.New("unknown DNS message type: " + s)
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-15 18:14:20 -04:00
|
|
|
// serveSuggestExitNode serves a POST endpoint for returning a suggested exit node.
|
|
|
|
|
func (h *Handler) serveSuggestExitNode(w http.ResponseWriter, r *http.Request) {
|
2025-10-01 19:18:46 -07:00
|
|
|
if !buildfeatures.HasUseExitNode {
|
|
|
|
|
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-11 14:22:30 -04:00
|
|
|
if r.Method != httpm.GET {
|
2024-04-15 18:14:20 -04:00
|
|
|
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
res, err := h.b.SuggestExitNode()
|
|
|
|
|
if err != nil {
|
2025-04-15 08:28:48 -07:00
|
|
|
WriteErrorJSON(w, err)
|
2024-04-15 18:14:20 -04:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(res)
|
|
|
|
|
}
|
2025-09-24 18:37:42 -05:00
|
|
|
|
|
|
|
|
// Shutdown is an eventbus value published when tailscaled shutdown
|
|
|
|
|
// is requested via LocalAPI. Its only consumer is [ipnserver.Server].
|
|
|
|
|
type Shutdown struct{}
|
|
|
|
|
|
|
|
|
|
// serveShutdown shuts down tailscaled. It requires write access
|
|
|
|
|
// and the [pkey.AllowTailscaledRestart] policy to be enabled.
|
|
|
|
|
// See tailscale/corp#32674.
|
|
|
|
|
func (h *Handler) serveShutdown(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != httpm.POST {
|
|
|
|
|
http.Error(w, "only POST allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !h.PermitWrite {
|
|
|
|
|
http.Error(w, "shutdown access denied", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
polc := h.b.Sys().PolicyClientOrDefault()
|
|
|
|
|
if permitShutdown, _ := polc.GetBoolean(pkey.AllowTailscaledRestart, false); !permitShutdown {
|
|
|
|
|
http.Error(w, "shutdown access denied by policy", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ec := h.eventBus.Client("localapi.Handler")
|
|
|
|
|
defer ec.Close()
|
|
|
|
|
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
if f, ok := w.(http.Flusher); ok {
|
|
|
|
|
f.Flush()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
eventbus.Publish[Shutdown](ec).Publish(Shutdown{})
|
|
|
|
|
}
|
2025-09-24 15:02:57 -07:00
|
|
|
|
|
|
|
|
func (h *Handler) serveGetAppcRouteInfo(w http.ResponseWriter, r *http.Request) {
|
2025-09-30 13:11:48 -07:00
|
|
|
if !buildfeatures.HasAppConnectors {
|
|
|
|
|
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-09-24 15:02:57 -07:00
|
|
|
if r.Method != httpm.GET {
|
|
|
|
|
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
res, err := h.b.ReadRouteInfo()
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, ipn.ErrStateNotExist) {
|
2025-10-02 09:31:42 -07:00
|
|
|
res = &appctype.RouteInfo{}
|
2025-09-24 15:02:57 -07:00
|
|
|
} else {
|
|
|
|
|
WriteErrorJSON(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
json.NewEncoder(w).Encode(res)
|
|
|
|
|
}
|