{"record":{"id":"e0f1d6039da7c268","repo":"juanfont/headscale","slug":"must-be-a-positive-duration","errorCode":null,"errorMessage":"must be a positive duration","messagePattern":"must be a positive duration","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"hscontrol/policy/v2/types.go","lineNumber":48,"sourceCode":"\tjson.MatchCaseInsensitiveNames(true),\n\tjson.RejectUnknownMembers(true),\n}\n\nconst Wildcard = Asterix(0)\n\nvar ErrAutogroupSelfRequiresPerNodeResolution = errors.New(\"autogroup:self requires per-node resolution and cannot be resolved in this context\")\n\nvar ErrUndefinedTagReference = errors.New(\"references undefined tag\")\n\n// SSH validation errors.\nvar (\n\tErrSSHTagSourceToUserDest             = errors.New(\"tags in SSH source cannot access user-owned devices\")\n\tErrSSHUserDestRequiresSameUser        = errors.New(\"user destination requires source to contain only that same user\")\n\tErrSSHAutogroupSelfRequiresUserSource = errors.New(\"autogroup:self destination requires source to contain only users or groups, not tags or autogroup:tagged\")\n\tErrSSHTagSourceToAutogroupMember      = errors.New(\"tags in SSH source cannot access autogroup:member (user-owned devices)\")\n\tErrSSHWildcardDestination             = errors.New(\"wildcard (*) is not supported as SSH destination\")\n\tErrSSHCheckPeriodAboveMax             = errors.New(\"is above the max (168h)\")\n\tErrSSHCheckPeriodNegative             = errors.New(\"must be a positive duration\")\n\tErrSSHCheckPeriodOnNonCheck           = errors.New(\"checkPeriod is only valid with action \\\"check\\\"\")\n\tErrInvalidLocalpart                   = errors.New(\"invalid localpart format, must be localpart:*@<domain>\")\n\tErrSSHUsersMustBeSpecified            = errors.New(\"users must be specified\")\n\tErrSSHUserInvalid                     = errors.New(\"is not valid\")\n\tErrSSHAcceptEnvEmpty                  = errors.New(\"acceptEnv values cannot be empty\")\n\tErrSSHActionMustBeSpecified           = errors.New(\"action must be specified\")\n\tErrSSHActionInvalid                   = errors.New(\"is not a valid action\")\n\tErrSSHDestinationHostAlias            = errors.New(\"invalid dst\")\n\tErrTagNameMustStartWithLetter         = errors.New(\"tag names must start with a letter, after 'tag:'\")\n\tErrGroupMembersCannotBeRecursive      = errors.New(\"group members cannot be recursive\")\n)\n\n// SSH check period constants per Tailscale docs:\n// https://tailscale.com/docs/features/tailscale-ssh#checkperiod\n// SaaS imposes no minimum (0s is accepted) so headscale matches.\nconst (\n\tSSHCheckPeriodDefault = 12 * time.Hour\n\tSSHCheckPeriodMax     = 7 * 24 * time.Hour","sourceCodeStart":30,"sourceCodeEnd":66,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/policy/v2/types.go#L30-L66","documentation":"ErrSSHCheckPeriodNegative is a sentinel in hscontrol/policy/v2/types.go:48 returned when a Tailscale SSH rule's checkPeriod parses to a negative duration. headscale mirrors Tailscale SaaS: 0s is accepted (no minimum), but a negative value is meaningless for a re-check interval and is rejected during policy validation at load time.","triggerScenarios":"An ssh rule in the policy file sets checkPeriod to a negative Go duration string (e.g. \"checkPeriod\": \"-5m\") with action \"check\". Validation in types.go (~line 2859) wraps it as fmt.Errorf(\"checkPeriod %s %w\", p.Duration, ErrSSHCheckPeriodNegative) and the whole policy load fails.","commonSituations":"Typo in the policy HuJSON/JSON (a stray '-' or an en-dash pasted from docs), migrating a policy between tools that sign durations differently, or a templating/system that computes checkPeriod arithmetically and can go negative.","solutions":["Fix the checkPeriod value in the ssh rule to a positive duration (\"5m\", \"12h\") or \"0s\" if you want the SaaS-accepted no-minimum value","Remove checkPeriod entirely to fall back to the default (SSHCheckPeriodDefault = 12h) if you did not intend to set it","Re-apply/reload the policy and confirm headscale starts or `headscale policy set` succeeds"],"exampleFix":"// before\n{\"action\": \"check\", \"users\": [\"autogroup:nonroot\"], \"dst\": [\"tag:srv:user1\"], \"checkPeriod\": \"-5m\"}\n// after\n{\"action\": \"check\", \"users\": [\"autogroup:nonroot\"], \"dst\": [\"tag:srv:user1\"], \"checkPeriod\": \"5m\"}","handlingStrategy":"validation","validationCode":"// before applying: every ssh rule with action \"check\" must have a non-negative checkPeriod\nfor _, r := range policy.SSH {\n    if r.CheckPeriod != nil && r.CheckPeriod.Duration() < 0 {\n        return fmt.Errorf(\"rule %v: negative checkPeriod %s\", r, r.CheckPeriod)\n    }\n}","typeGuard":null,"tryCatchPattern":"if err := hpolicy.LoadPolicy(bytes); err != nil {\n    if errors.Is(err, hpolicy.ErrSSHCheckPeriodNegative) { /* point operator at the rule */ }\n}","preventionTips":["Lint policy files for negative duration strings before deploy","Keep policies in version control and validate with `headscale policy check`-style dry runs in CI"],"tags":["policy","ssh","configuration","validation","go"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}