[Feature] Add option to expire node on disconnect #979

Closed
opened 2025-12-29 02:27:00 +01:00 by adam · 7 comments
Owner

Originally created by @dmeremyanin on GitHub (Mar 21, 2025).

Use case

We want to enforce user authentication on every connection attempt to ensure 2FA is used each time. We've configured an OIDC provider (Authentik) for this purpose, but currently, Headscale doesn't require re-authentication unless the node is expired.

Setting a shorter expiration time (e.g., 1 hour) isn't ideal, as it forces Tailscale to disconnect frequently, interrupting user access. We need a solution that ensures authentication on each connection attempt without forcing disconnections every few hours.

Description

This feature enforces stricter authentication by expiring the node upon disconnection. When enabled, it ensures that users must re-authenticate via the OIDC provider on the next connection attempt, requiring 2FA each time.

Contribution

  • I can write the design doc for this feature
  • I can contribute this feature

How can it be implemented?

I propose adding a new configuration option, expire_node_on_disconnect, which will be disabled by default to preserve current behavior. When enabled, the node will expire upon disconnection, and users will be prompted to re-authenticate on the next connection attempt.

The expiration can be implemented in the updateNodeOnlineStatus function here: 707438f25e/hscontrol/poll.go (L404-L410)

Originally created by @dmeremyanin on GitHub (Mar 21, 2025). ### Use case We want to enforce user authentication on every connection attempt to ensure 2FA is used each time. We've configured an OIDC provider (Authentik) for this purpose, but currently, Headscale doesn't require re-authentication unless the node is expired. Setting a shorter expiration time (e.g., 1 hour) isn't ideal, as it forces Tailscale to disconnect frequently, interrupting user access. We need a solution that ensures authentication on each connection attempt without forcing disconnections every few hours. ### Description This feature enforces stricter authentication by expiring the node upon disconnection. When enabled, it ensures that users must re-authenticate via the OIDC provider on the next connection attempt, requiring 2FA each time. ### Contribution - [ ] I can write the design doc for this feature - [x] I can contribute this feature ### How can it be implemented? I propose adding a new configuration option, `expire_node_on_disconnect`, which will be disabled by default to preserve current behavior. When enabled, the node will expire upon disconnection, and users will be prompted to re-authenticate on the next connection attempt. The expiration can be implemented in the `updateNodeOnlineStatus` function here: https://github.com/juanfont/headscale/blob/707438f25e06a3c52b673b3df0daaa8f8428e543/hscontrol/poll.go#L404-L410
adam added the enhancement label 2025-12-29 02:27:00 +01:00
adam closed this issue 2025-12-29 02:27:00 +01:00
Author
Owner

@dmeremyanin commented on GitHub (Mar 21, 2025):

I've implemented a draft with the proposed change: 79521df1b0

If it looks good, I'll open a PR.

@dmeremyanin commented on GitHub (Mar 21, 2025): I've implemented a draft with the proposed change: https://github.com/dmeremyanin/headscale/commit/79521df1b031d7a6592f5f1bb6b66a29d8dbebc3 If it looks good, I'll open a PR.
Author
Owner

@dmeremyanin commented on GitHub (Mar 23, 2025):

@kradalby would really appreciate your review when you have a chance. Thanks

@dmeremyanin commented on GitHub (Mar 23, 2025): @kradalby would really appreciate your review when you have a chance. Thanks
Author
Owner

@kradalby commented on GitHub (Mar 24, 2025):

Hi, sorry, we try to be very strict and avoid infinite accessibility. This feature will both add more surface for us to keep track of, but also prevent a footgun for people who dont understand it.

I do not think this mode would be the right way to do things from a security perspective as people can just make sure they keep the connection alive. In addition it would be a very bad user experience if the headscale/tailscale or laptop gets into a flaky connection state.

@kradalby commented on GitHub (Mar 24, 2025): Hi, sorry, we try to be very strict and avoid infinite accessibility. This feature will both add more surface for us to keep track of, but also prevent a footgun for people who dont understand it. I do not think this mode would be the right way to do things from a security perspective as people can just make sure they keep the connection alive. In addition it would be a very bad user experience if the headscale/tailscale or laptop gets into a flaky connection state.
Author
Owner

@dmeremyanin commented on GitHub (Mar 24, 2025):

I agree with your concerns. However, we can set a maximum connection time limit (e.g., 1 day) to ensure it doesn't interrupt normal usage while still enforcing 2FA on the next connection attempt, and preventing established connections from being used for too long. As far as I understand, this wouldn't be possible without the proposed change.

I thought it was a pretty small change, easy to implement, and could be disabled by default via a config flag to preserve the current behavior, which would help make Headscale a bit more secure. If you feel this approach doesn't align with the Headscale vision, I completely understand.

@dmeremyanin commented on GitHub (Mar 24, 2025): I agree with your concerns. However, we can set a maximum connection time limit (e.g., 1 day) to ensure it doesn't interrupt normal usage while still enforcing 2FA on the next connection attempt, and preventing established connections from being used for too long. As far as I understand, this wouldn't be possible without the proposed change. I thought it was a pretty small change, easy to implement, and could be disabled by default via a config flag to preserve the current behavior, which would help make Headscale a bit more secure. If you feel this approach doesn't align with the Headscale vision, I completely understand.
Author
Owner

@kradalby commented on GitHub (Mar 24, 2025):

If you feel this approach doesn't align with the Headscale vision, I completely understand.

I do not think it aligns, we aim to mirror upstream Tailscale SaaS, and expanding and supporting edgecases which is not possible there is making it harder for us to focus on achieving that.

I think the other aspect that is quite important is that Tailscale's platform is essentially distributed, the control server is really only meaningful to exchange the information and a continuous connection to it is more of a "best case". For example, if Headscale (or Tailscale SaaS) goes down, then all nodes currently online will still work as they have a view of the world.

This change will break this property, which is one of the core parts of the platform.

@kradalby commented on GitHub (Mar 24, 2025): > If you feel this approach doesn't align with the Headscale vision, I completely understand. I do not think it aligns, we aim to mirror upstream Tailscale SaaS, and expanding and supporting edgecases which is not possible there is making it harder for us to focus on achieving that. I think the other aspect that is quite important is that Tailscale's platform is essentially distributed, the control server is really only meaningful to exchange the information and a continuous connection to it is more of a "best case". For example, if Headscale (or Tailscale SaaS) goes down, then all nodes currently online will still work as they have a view of the world. This change will break this property, which is one of the core parts of the platform.
Author
Owner

@dmeremyanin commented on GitHub (Mar 24, 2025):

It makes total sense to me now, especially the part about the platform's distributed nature. I hadn't fully considered the case where Headscale goes down. Thanks for taking the time to explain!

@dmeremyanin commented on GitHub (Mar 24, 2025): It makes total sense to me now, especially the part about the platform's distributed nature. I hadn't fully considered the case where Headscale goes down. Thanks for taking the time to explain!
Author
Owner

@dmeremyanin commented on GitHub (Mar 25, 2025):

Just in case someone wants to achieve the behavior described in the issue, here's the code we added to the crontab to run every minute:

#!/usr/bin/python3

import json
import subprocess
import time

current_time = time.time()

# Time period during which a node can reconnect without requiring re-authentication
# through the OIDC provider after disconnecting
#
# Set to 30 minutes
reconnect_grace_period = 30 * 60

nodes_cmd = subprocess.run(["headscale", "nodes", "list", "-o", "json-line"], capture_output=True, check=True)

nodes = json.loads(nodes_cmd.stdout)

for node in nodes:
  # Skip if the node is online
  if node.get('online'):
    continue

  # Skip if the node is not registered via OIDC provider
  if node['register_method'] != 3:
    continue

  # Skip if the node is expired
  if node['expiry']['seconds'] < current_time:
    continue

  # Skip if the node disconnected within the reconnect grace period and doesn't need re-authentication
  if current_time - node['last_seen']['seconds'] < reconnect_grace_period:
    continue

  node_id = str(node['id'])

  subprocess.run(["headscale", "nodes", "expire", "-i", node_id])

This script forces users to re-authenticate on the next connection attempt (and optionally use two-factor authentication) if they have been disconnected for longer than 30 minutes. If the user reconnects within that period, the connection proceeds normally without requiring re-authentication.

It's worth mentioning that this should be combined with the oidc.expiry configuration option to ensure that users can't stay connected indefinitely (or for too long). We have set it to 24 hours to enforce a maximum connection time.

@dmeremyanin commented on GitHub (Mar 25, 2025): Just in case someone wants to achieve the behavior described in the issue, here's the code we added to the crontab to run every minute: ```python #!/usr/bin/python3 import json import subprocess import time current_time = time.time() # Time period during which a node can reconnect without requiring re-authentication # through the OIDC provider after disconnecting # # Set to 30 minutes reconnect_grace_period = 30 * 60 nodes_cmd = subprocess.run(["headscale", "nodes", "list", "-o", "json-line"], capture_output=True, check=True) nodes = json.loads(nodes_cmd.stdout) for node in nodes: # Skip if the node is online if node.get('online'): continue # Skip if the node is not registered via OIDC provider if node['register_method'] != 3: continue # Skip if the node is expired if node['expiry']['seconds'] < current_time: continue # Skip if the node disconnected within the reconnect grace period and doesn't need re-authentication if current_time - node['last_seen']['seconds'] < reconnect_grace_period: continue node_id = str(node['id']) subprocess.run(["headscale", "nodes", "expire", "-i", node_id]) ``` This script forces users to re-authenticate on the next connection attempt (and optionally use two-factor authentication) if they have been disconnected for longer than 30 minutes. If the user reconnects within that period, the connection proceeds normally without requiring re-authentication. It's worth mentioning that this should be combined with the `oidc.expiry` configuration option to ensure that users can't stay connected indefinitely (or for too long). We have set it to 24 hours to enforce a maximum connection time.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: starred/headscale#979