api/v2: add read-only tailnet settings endpoint

Report the settings the k8s operator reads, computed from config;
writes return 501.
This commit is contained in:
Kristoffer Dalby
2026-06-20 20:16:51 +00:00
parent 3cddc07026
commit e2e9fe3081
+99
View File
@@ -0,0 +1,99 @@
package apiv2
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
)
func init() {
registrations = append(registrations, registerSettings)
}
// TailnetSettings is the Tailscale tailnet-settings response. Headscale's config
// is file-based and mostly not runtime-mutable, so only a few fields carry a
// real value; the rest report the honest "off"/default.
type TailnetSettings struct {
ACLsExternallyManagedOn bool `json:"aclsExternallyManagedOn"`
ACLsExternalLink string `json:"aclsExternalLink"`
DevicesApprovalOn bool `json:"devicesApprovalOn"`
DevicesAutoUpdatesOn bool `json:"devicesAutoUpdatesOn"`
DevicesKeyDurationDays int `json:"devicesKeyDurationDays"`
UsersApprovalOn bool `json:"usersApprovalOn"`
UsersRoleAllowedToJoinExternalTailnets string `json:"usersRoleAllowedToJoinExternalTailnets"`
NetworkFlowLoggingOn bool `json:"networkFlowLoggingOn"`
RegionalRoutingOn bool `json:"regionalRoutingOn"`
PostureIdentityCollectionOn bool `json:"postureIdentityCollectionOn"`
HTTPSEnabled bool `json:"httpsEnabled"`
}
type (
getSettingsInput struct {
Tailnet string `path:"tailnet"`
}
settingsOutput struct {
Body TailnetSettings
}
patchSettingsInput struct {
Tailnet string `path:"tailnet"`
// Accepted and ignored; updating settings is not supported.
Body json.RawMessage
}
)
func registerSettings(api huma.API, b Backend) {
settingsTags := []string{"TailnetSettings", "Tailscale compat"}
huma.Register(api, requireScope(huma.Operation{
OperationID: "getTailnetSettings",
Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/settings",
Summary: "Get tailnet settings",
Tags: settingsTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeFeatureSettingsRead), func(ctx context.Context, in *getSettingsInput) (*settingsOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
cfg := b.Cfg
return &settingsOutput{Body: TailnetSettings{
// File-mode policy is genuinely externally managed (read-only via API).
ACLsExternallyManagedOn: cfg.Policy.Mode == types.PolicyModeFile,
DevicesKeyDurationDays: int(cfg.Node.Expiry / (24 * time.Hour)),
HTTPSEnabled: cfg.TLS.CertPath != "" || cfg.TLS.LetsEncrypt.Hostname != "",
UsersRoleAllowedToJoinExternalTailnets: "none",
}}, nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "updateTailnetSettings",
Method: http.MethodPatch,
Path: "/api/v2/tailnet/{tailnet}/settings",
Summary: "Update tailnet settings",
Tags: settingsTags,
Security: security,
// The body is accepted but ignored; skip validation.
SkipValidateBody: true,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented},
}, ScopeFeatureSettings), func(ctx context.Context, in *patchSettingsInput) (*settingsOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
return nil, huma.Error501NotImplemented(
"updating tailnet settings is not supported by Headscale",
)
})
}