Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ sequentially through each stable release, selecting the latest patch version ava
- API clients should use the `Tags` field instead of `ValidTags`
- The `headscale nodes list` CLI command now always shows a Tags column and the `--tags` flag has been removed
- **PreAuthKey CLI**: Commands now use ID-based operations instead of user+key combinations [#2992](https://github.com/juanfont/headscale/pull/2992)

- `headscale preauthkeys create` no longer requires `--user` flag (optional for tracking creation)
- `headscale preauthkeys list` lists all keys (no longer filtered by user)
- `headscale preauthkeys expire --id <ID>` replaces `--user <USER> <KEY>`
Expand Down Expand Up @@ -120,6 +121,7 @@ sequentially through each stable release, selecting the latest patch version ava
- When `false`, unverified emails are allowed for OIDC authentication and the email address is stored in the user
profile regardless of its verification state.
- **SSH Policy**: Wildcard (`*`) is no longer supported as an SSH destination [#3009](https://github.com/juanfont/headscale/issues/3009)

- Use `autogroup:member` for user-owned devices
- Use `autogroup:tagged` for tagged devices
- Use specific tags (e.g., `tag:server`) for targeted access
Expand All @@ -139,6 +141,7 @@ sequentially through each stable release, selecting the latest patch version ava
- **SSH Policy**: SSH source/destination validation now enforces Tailscale's security model [#3010](https://github.com/juanfont/headscale/issues/3010)

Per [Tailscale SSH documentation](https://tailscale.com/kb/1193/tailscale-ssh), the following rules are now enforced:

1. **Tags cannot SSH to user-owned devices**: SSH rules with `tag:*` or `autogroup:tagged` as source cannot have username destinations (e.g., `alice@`) or `autogroup:member`/`autogroup:self` as destination
2. **Username destinations require same-user source**: If destination is a specific username (e.g., `alice@`), the source must be that exact same user only. Use `autogroup:self` for same-user SSH access instead

Expand Down Expand Up @@ -186,6 +189,7 @@ sequentially through each stable release, selecting the latest patch version ava
- Add `taildrop.enabled` configuration option to enable/disable Taildrop file sharing [#2955](https://github.com/juanfont/headscale/pull/2955)
- Allow disabling the metrics server by setting empty `metrics_listen_addr` [#2914](https://github.com/juanfont/headscale/pull/2914)
- Log ACME/autocert errors for easier debugging [#2933](https://github.com/juanfont/headscale/pull/2933)
- Certificates now reload on SIGHUP signal [#3041](https://github.com/juanfont/headscale/pull/3041)
- Improve CLI list output formatting [#2951](https://github.com/juanfont/headscale/pull/2951)
- Use Debian 13 distroless base images for containers [#2944](https://github.com/juanfont/headscale/pull/2944)
- Fix ACL policy not applied to new OIDC nodes until client restart [#2890](https://github.com/juanfont/headscale/pull/2890)
Expand Down
74 changes: 60 additions & 14 deletions hscontrol/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ type Headscale struct {
mapBatcher mapper.Batcher

clientStreamsOpen sync.WaitGroup

// TLS certificate for manual TLS configuration (non-ACME).
// Protected by tlsCertMu for concurrent access during SIGHUP reload.
tlsCertMu sync.RWMutex
tlsCert *tls.Certificate
}

var (
Expand Down Expand Up @@ -823,19 +828,28 @@ func (h *Headscale) Serve() error {
case syscall.SIGHUP:
log.Info().
Str("signal", sig.String()).
Msg("Received SIGHUP, reloading ACL policy")
Msg("Received SIGHUP, reloading TLS certificate")

if h.cfg.Policy.IsEmpty() {
continue
// Reload TLS certificate if using manual TLS (not ACME/Let's Encrypt)
if h.cfg.TLS.CertPath != "" && h.cfg.TLS.LetsEncrypt.Hostname == "" {
if err := h.reloadTLSCertificate(); err != nil {
log.Error().Err(err).Msg("reloading TLS certificate")
}
}

changes, err := h.state.ReloadPolicy()
if err != nil {
log.Error().Err(err).Msgf("reloading policy")
continue
}
log.Info().
Str("signal", sig.String()).
Msg("Received SIGHUP, reloading ACL policy")

h.Change(changes...)
// Reload ACL policy
if !h.cfg.Policy.IsEmpty() {
changes, err := h.state.ReloadPolicy()
if err != nil {
log.Error().Err(err).Msg("reloading ACL policy")
} else {
h.Change(changes...)
}
}

default:
info := func(msg string) { log.Info().Msg(msg) }
Expand Down Expand Up @@ -995,15 +1009,47 @@ func (h *Headscale) getTLSSettings() (*tls.Config, error) {
}

tlsConfig := &tls.Config{
NextProtos: []string{"http/1.1"},
Certificates: make([]tls.Certificate, 1),
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
MinVersion: tls.VersionTLS12,
}

tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(h.cfg.TLS.CertPath, h.cfg.TLS.KeyPath)
if err := h.reloadTLSCertificate(); err != nil {
return nil, err
}

return tlsConfig, err
tlsConfig.GetCertificate = h.getTLSCertificate

return tlsConfig, nil
}
}

// reloadTLSCertificate loads or reloads the TLS certificate from disk.
// This is called on startup and on SIGHUP for certificate rotation.
func (h *Headscale) reloadTLSCertificate() error {
cert, err := tls.LoadX509KeyPair(h.cfg.TLS.CertPath, h.cfg.TLS.KeyPath)
if err != nil {
return fmt.Errorf("loading TLS certificate: %w", err)
}

h.tlsCertMu.Lock()
h.tlsCert = &cert
h.tlsCertMu.Unlock()

log.Info().
Str("cert_path", h.cfg.TLS.CertPath).
Str("key_path", h.cfg.TLS.KeyPath).
Msg("TLS certificate loaded")

return nil
}

// getTLSCertificate returns the current TLS certificate.
// It implements the tls.Config.GetCertificate callback signature.
func (h *Headscale) getTLSCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
h.tlsCertMu.RLock()
defer h.tlsCertMu.RUnlock()

return h.tlsCert, nil
}

func readOrCreatePrivateKey(path string) (*key.MachinePrivate, error) {
Expand Down
Loading