diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index a4a695c63..c04b716b5 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -25,6 +25,7 @@ import ( "sync" "time" + "golang.org/x/exp/slices" "tailscale.com/client/tailscale/apitype" "tailscale.com/envknob" "tailscale.com/health" @@ -53,6 +54,7 @@ var handler = map[string]localAPIHandler{ "cert/": (*Handler).serveCert, "file-put/": (*Handler).serveFilePut, "files/": (*Handler).serveFiles, + "profiles/": (*Handler).serveProfiles, // The other /localapi/v0/NAME handlers are exact matches and contain only NAME // without a trailing slash: @@ -1099,6 +1101,89 @@ func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) } +// 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/: output profile (JSON-ecoded ipn.LoginProfile) +// - POST /profiles/: switch to profile (no response) +// - DELETE /profiles/: 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 + } + suffix, ok := strs.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/profiles/") + if !ok { + panic("misconfigured") + } + if suffix == "" { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(h.b.ListProfiles()) + case http.MethodPut: + 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 { + case http.MethodGet: + 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 { + case http.MethodGet: + profiles := h.b.ListProfiles() + profileIndex := slices.IndexFunc(profiles, func(p ipn.LoginProfile) bool { + return p.ID == profileID + }) + 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]) + case http.MethodPost: + err := h.b.SwitchProfile(profileID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + case http.MethodDelete: + 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) + } +} + func defBool(a string, def bool) bool { if a == "" { return def