mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-26 02:04:53 +09:00
2da47a77d6
The interstitial was the body of /oidc/callback, the URL carrying the
single-use code, so any reload re-entered the spent exchange. Redirect to
GET /register/confirm/{auth_id}, also missing from the route table.
0.29 lacks the authPathURL helper from main, so it is added here.
(cherry picked from commit 6d377b5348)
413 lines
12 KiB
Go
413 lines
12 KiB
Go
package hscontrol
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/assets"
|
|
"github.com/juanfont/headscale/hscontrol/templates"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/rs/zerolog/log"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
const (
|
|
reservedResponseHeaderSize = 4
|
|
)
|
|
|
|
// httpError logs an error and sends an HTTP error response with the given.
|
|
func httpError(w http.ResponseWriter, err error) {
|
|
if herr, ok := errors.AsType[HTTPError](err); ok {
|
|
http.Error(w, herr.Msg, herr.Code)
|
|
log.Error().Err(herr.Err).Int("code", herr.Code).Msgf("user msg: %s", herr.Msg)
|
|
} else {
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
log.Error().Err(err).Int("code", http.StatusInternalServerError).Msg("http internal server error")
|
|
}
|
|
}
|
|
|
|
// httpUserError logs an error and sends a styled HTML error page.
|
|
// Use this for browser-facing error paths (OIDC, registration confirm)
|
|
// where the user should see a branded page instead of plain text.
|
|
// Technical details go to the server log; the HTML page only shows
|
|
// an actionable message derived from the HTTP status code.
|
|
func httpUserError(w http.ResponseWriter, err error) {
|
|
code := http.StatusInternalServerError
|
|
|
|
if herr, ok := errors.AsType[HTTPError](err); ok {
|
|
if herr.Code != 0 {
|
|
code = herr.Code
|
|
}
|
|
|
|
log.Error().Err(herr.Err).Int("code", code).Msgf("user msg: %s", herr.Msg)
|
|
} else {
|
|
log.Error().Err(err).Int("code", code).Msg("http internal server error")
|
|
}
|
|
|
|
userMsg := userMessageForStatusCode(code)
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
|
|
page := templates.AuthError(templates.AuthErrorResult{
|
|
Title: "Headscale - Error",
|
|
Heading: http.StatusText(code),
|
|
Message: userMsg,
|
|
})
|
|
|
|
_, werr := w.Write([]byte(page.Render()))
|
|
if werr != nil {
|
|
log.Error().Err(werr).Msg("failed to write HTML error response")
|
|
}
|
|
}
|
|
|
|
func userMessageForStatusCode(code int) string {
|
|
switch {
|
|
case code == http.StatusUnauthorized || code == http.StatusForbidden:
|
|
return "You are not authorized. Please contact your administrator."
|
|
case code == http.StatusGone:
|
|
// Overwhelmingly a reload or a back button on a link the user
|
|
// already used, not a failure, so lead with that.
|
|
return "This link has already been used or has expired. " +
|
|
"If your device is connected you are done; otherwise start the login again."
|
|
case code >= 400 && code < 500:
|
|
return "The request could not be processed. Please try again."
|
|
default:
|
|
return "Something went wrong. Please try again later."
|
|
}
|
|
}
|
|
|
|
// HTTPError represents an error that is surfaced to the user via web.
|
|
type HTTPError struct {
|
|
Code int // HTTP response code to send to client; 0 means 500
|
|
Msg string // Response body to send to client
|
|
Err error // Detailed error to log on the server
|
|
}
|
|
|
|
func (e HTTPError) Error() string { return fmt.Sprintf("http error[%d]: %s, %s", e.Code, e.Msg, e.Err) }
|
|
func (e HTTPError) Unwrap() error { return e.Err }
|
|
|
|
// NewHTTPError returns an HTTPError containing the given information.
|
|
func NewHTTPError(code int, msg string, err error) HTTPError {
|
|
return HTTPError{Code: code, Msg: msg, Err: err}
|
|
}
|
|
|
|
var errMethodNotAllowed = NewHTTPError(http.StatusMethodNotAllowed, "method not allowed", nil)
|
|
|
|
var ErrRegisterMethodCLIDoesNotSupportExpire = errors.New(
|
|
"machines registered with CLI do not support expiry",
|
|
)
|
|
|
|
func parseCapabilityVersion(req *http.Request) (tailcfg.CapabilityVersion, error) {
|
|
clientCapabilityStr := req.URL.Query().Get("v")
|
|
|
|
if clientCapabilityStr == "" {
|
|
return 0, NewHTTPError(http.StatusBadRequest, "capability version must be set", nil)
|
|
}
|
|
|
|
clientCapabilityVersion, err := strconv.Atoi(clientCapabilityStr)
|
|
if err != nil {
|
|
return 0, NewHTTPError(http.StatusBadRequest, "invalid capability version", fmt.Errorf("parsing capability version: %w", err))
|
|
}
|
|
|
|
return tailcfg.CapabilityVersion(clientCapabilityVersion), nil
|
|
}
|
|
|
|
// verifyBodyLimit caps the request body for /verify. The DERP verify
|
|
// protocol payload ([tailcfg.DERPAdmitClientRequest]) is a few hundred
|
|
// bytes; 4 KiB is generous and prevents an unauthenticated client from
|
|
// OOMing the public router with arbitrarily large POSTs.
|
|
const verifyBodyLimit int64 = 4 * 1024
|
|
|
|
func (h *Headscale) handleVerifyRequest(
|
|
req *http.Request,
|
|
writer io.Writer,
|
|
) error {
|
|
body, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
return NewHTTPError(http.StatusRequestEntityTooLarge, "request body too large", fmt.Errorf("reading request body: %w", err))
|
|
}
|
|
|
|
var derpAdmitClientRequest tailcfg.DERPAdmitClientRequest
|
|
if err := json.Unmarshal(body, &derpAdmitClientRequest); err != nil { //nolint:noinlineerr
|
|
return NewHTTPError(http.StatusBadRequest, "Bad Request: invalid JSON", fmt.Errorf("parsing DERP client request: %w", err))
|
|
}
|
|
|
|
nodes := h.state.ListNodes()
|
|
|
|
// Check if any node has the requested NodeKey
|
|
var nodeKeyFound bool
|
|
|
|
for _, node := range nodes.All() {
|
|
if node.NodeKey() == derpAdmitClientRequest.NodePublic {
|
|
nodeKeyFound = true
|
|
break
|
|
}
|
|
}
|
|
|
|
resp := &tailcfg.DERPAdmitClientResponse{
|
|
Allow: nodeKeyFound,
|
|
}
|
|
|
|
return json.NewEncoder(writer).Encode(resp)
|
|
}
|
|
|
|
// VerifyHandler see https://github.com/tailscale/tailscale/blob/964282d34f06ecc06ce644769c66b0b31d118340/derp/derp_server.go#L1159
|
|
// DERP use verifyClientsURL to verify whether a client is allowed to connect to the DERP server.
|
|
func (h *Headscale) VerifyHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
if req.Method != http.MethodPost {
|
|
httpError(writer, errMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
req.Body = http.MaxBytesReader(writer, req.Body, verifyBodyLimit)
|
|
|
|
err := h.handleVerifyRequest(req, writer)
|
|
if err != nil {
|
|
httpError(writer, err)
|
|
return
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
}
|
|
|
|
// KeyHandler provides the Headscale pub key
|
|
// Listens in /key.
|
|
func (h *Headscale) KeyHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
// New Tailscale clients send a 'v' parameter to indicate the CurrentCapabilityVersion
|
|
capVer, err := parseCapabilityVersion(req)
|
|
if err != nil {
|
|
httpError(writer, err)
|
|
return
|
|
}
|
|
|
|
// Only disclose the Noise public key to clients this server can
|
|
// actually complete a handshake with. Gating on the same floor the
|
|
// Noise handshake enforces (capver.MinSupportedCapabilityVersion, see
|
|
// isSupportedVersion in noise.go) keeps /key consistent with /ts2021:
|
|
// versions the handshake would reject get a clear rejection here
|
|
// instead of a key that only serves as a version-boundary oracle.
|
|
// See https://github.com/juanfont/headscale/issues/3380.
|
|
if !isSupportedVersion(capVer) {
|
|
httpError(writer, NewHTTPError(http.StatusBadRequest, "unsupported client version", unsupportedClientError(capVer)))
|
|
return
|
|
}
|
|
|
|
resp := tailcfg.OverTLSPublicKeyResponse{
|
|
PublicKey: h.noisePrivateKey.Public(),
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
|
|
err = json.NewEncoder(writer).Encode(resp)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to encode public key response")
|
|
}
|
|
}
|
|
|
|
func (h *Headscale) HealthHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
respond := func(err error) {
|
|
writer.Header().Set("Content-Type", "application/health+json; charset=utf-8")
|
|
|
|
res := struct {
|
|
Status string `json:"status"`
|
|
}{
|
|
Status: "pass",
|
|
}
|
|
|
|
if err != nil {
|
|
writer.WriteHeader(http.StatusInternalServerError)
|
|
|
|
res.Status = "fail"
|
|
}
|
|
|
|
encErr := json.NewEncoder(writer).Encode(res)
|
|
if encErr != nil {
|
|
log.Error().Err(encErr).Msg("failed to encode health response")
|
|
}
|
|
}
|
|
|
|
err := h.state.PingDB(req.Context())
|
|
if err != nil {
|
|
respond(err)
|
|
|
|
return
|
|
}
|
|
|
|
respond(nil)
|
|
}
|
|
|
|
func (h *Headscale) RobotsHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
writer.Header().Set("Content-Type", "text/plain")
|
|
writer.WriteHeader(http.StatusOK)
|
|
|
|
_, err := writer.Write([]byte("User-agent: *\nDisallow: /"))
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to write HTTP response")
|
|
}
|
|
}
|
|
|
|
// VersionHandler returns version information about the Headscale server
|
|
// Listens in /version.
|
|
func (h *Headscale) VersionHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.WriteHeader(http.StatusOK)
|
|
|
|
versionInfo := types.GetVersionInfo()
|
|
|
|
err := json.NewEncoder(writer).Encode(versionInfo)
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to write version response")
|
|
}
|
|
}
|
|
|
|
type AuthProviderWeb struct {
|
|
serverURL string
|
|
}
|
|
|
|
func NewAuthProviderWeb(serverURL string) *AuthProviderWeb {
|
|
return &AuthProviderWeb{
|
|
serverURL: serverURL,
|
|
}
|
|
}
|
|
|
|
// authPathURL builds an auth-flow URL of the form
|
|
// "<serverURL>/<kind>/<id>", trimming a trailing slash from serverURL.
|
|
func authPathURL(serverURL, kind string, authID types.AuthID) string {
|
|
return fmt.Sprintf(
|
|
"%s/%s/%s",
|
|
strings.TrimSuffix(serverURL, "/"),
|
|
kind,
|
|
authID.String(),
|
|
)
|
|
}
|
|
|
|
func (a *AuthProviderWeb) RegisterURL(authID types.AuthID) string {
|
|
return fmt.Sprintf(
|
|
"%s/register/%s",
|
|
strings.TrimSuffix(a.serverURL, "/"),
|
|
authID.String())
|
|
}
|
|
|
|
func (a *AuthProviderWeb) AuthURL(authID types.AuthID) string {
|
|
return fmt.Sprintf(
|
|
"%s/auth/%s",
|
|
strings.TrimSuffix(a.serverURL, "/"),
|
|
authID.String())
|
|
}
|
|
|
|
func (a *AuthProviderWeb) AuthHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
authID, err := authIDFromRequest(req)
|
|
if err != nil {
|
|
httpError(writer, err)
|
|
return
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
writer.WriteHeader(http.StatusOK)
|
|
|
|
_, err = writer.Write([]byte(templates.AuthWeb(
|
|
"Authentication check",
|
|
"Run the command below in the headscale server to approve this authentication request:",
|
|
"headscale auth approve --auth-id "+authID.String(),
|
|
).Render()))
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to write auth response")
|
|
}
|
|
}
|
|
|
|
func authIDFromRequest(req *http.Request) (types.AuthID, error) {
|
|
raw, err := urlParam[string](req, "auth_id")
|
|
if err != nil {
|
|
return "", NewHTTPError(http.StatusBadRequest, "invalid auth id", fmt.Errorf("parsing auth_id from URL: %w", err))
|
|
}
|
|
|
|
// We need to make sure we dont open for XSS style injections, if the parameter that
|
|
// is passed as a key is not parsable/validated as a NodePublic key, then fail to render
|
|
// the template and log an error.
|
|
authId, err := types.AuthIDFromString(raw)
|
|
if err != nil {
|
|
return "", NewHTTPError(http.StatusBadRequest, "invalid auth id", fmt.Errorf("parsing auth_id from URL: %w", err))
|
|
}
|
|
|
|
return authId, nil
|
|
}
|
|
|
|
// RegisterHandler shows a simple message in the browser to point to the CLI
|
|
// Listens in /register/:registration_id.
|
|
//
|
|
// This is not part of the Tailscale control API, as we could send whatever URL
|
|
// in the [tailcfg.RegisterResponse.AuthURL] field.
|
|
func (a *AuthProviderWeb) RegisterHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
authId, err := authIDFromRequest(req)
|
|
if err != nil {
|
|
httpError(writer, err)
|
|
return
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
writer.WriteHeader(http.StatusOK)
|
|
|
|
_, err = writer.Write([]byte(templates.AuthWeb(
|
|
"Node registration",
|
|
"Run the command below in the headscale server to add this node to your network:",
|
|
fmt.Sprintf("headscale auth register --auth-id %s --user USERNAME", authId.String()),
|
|
).Render()))
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to write register response")
|
|
}
|
|
}
|
|
|
|
func FaviconHandler(writer http.ResponseWriter, req *http.Request) {
|
|
writer.Header().Set("Content-Type", "image/png")
|
|
http.ServeContent(writer, req, "favicon.ico", time.Unix(0, 0), bytes.NewReader(assets.Favicon))
|
|
}
|
|
|
|
// BlankHandler returns a blank page with favicon linked.
|
|
func BlankHandler(writer http.ResponseWriter, res *http.Request) {
|
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
writer.WriteHeader(http.StatusOK)
|
|
|
|
_, err := writer.Write([]byte(templates.BlankPage().Render()))
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to write HTTP response")
|
|
}
|
|
}
|