Skip to content
Merged
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
10 changes: 6 additions & 4 deletions .github/workflows/gh-action-integration-generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ func findTests() []string {
return tests
}

func updateYAML(tests []string) {
func updateYAML(tests []string, testPath string) {
testsForYq := fmt.Sprintf("[%s]", strings.Join(tests, ", "))

yqCommand := fmt.Sprintf(
"yq eval '.jobs.integration-test.strategy.matrix.test = %s' ./test-integration.yaml -i",
"yq eval '.jobs.integration-test.strategy.matrix.test = %s' %s -i",
testsForYq,
testPath,
)
cmd := exec.Command("bash", "-c", yqCommand)

Expand All @@ -58,7 +59,7 @@ func updateYAML(tests []string) {
log.Fatalf("failed to run yq command: %s", err)
}

fmt.Println("YAML file updated successfully")
fmt.Printf("YAML file (%s) updated successfully\n", testPath)
}

func main() {
Expand All @@ -69,5 +70,6 @@ func main() {
quotedTests[i] = fmt.Sprintf("\"%s\"", test)
}

updateYAML(quotedTests)
updateYAML(quotedTests, "./test-integration.yaml")
updateYAML(quotedTests, "./test-integration-policyv2.yaml")
}
159 changes: 159 additions & 0 deletions .github/workflows/test-integration-policyv2.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
name: Integration Tests (policy v2)
# To debug locally on a branch, and when needing secrets
# change this to include `push` so the build is ran on
# the main repository.
on: [pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
integration-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
test:
- TestACLHostsInNetMapTable
- TestACLAllowUser80Dst
- TestACLDenyAllPort80
- TestACLAllowUserDst
- TestACLAllowStarDst
- TestACLNamedHostsCanReachBySubnet
- TestACLNamedHostsCanReach
- TestACLDevice1CanAccessDevice2
- TestPolicyUpdateWhileRunningWithCLIInDatabase
- TestAuthKeyLogoutAndReloginSameUser
- TestAuthKeyLogoutAndReloginNewUser
- TestAuthKeyLogoutAndReloginSameUserExpiredKey
- TestOIDCAuthenticationPingAll
- TestOIDCExpireNodesBasedOnTokenExpiry
- TestOIDC024UserCreation
- TestOIDCAuthenticationWithPKCE
- TestOIDCReloginSameNodeNewUser
- TestAuthWebFlowAuthenticationPingAll
- TestAuthWebFlowLogoutAndRelogin
- TestUserCommand
- TestPreAuthKeyCommand
- TestPreAuthKeyCommandWithoutExpiry
- TestPreAuthKeyCommandReusableEphemeral
- TestPreAuthKeyCorrectUserLoggedInCommand
- TestApiKeyCommand
- TestNodeTagCommand
- TestNodeAdvertiseTagCommand
- TestNodeCommand
- TestNodeExpireCommand
- TestNodeRenameCommand
- TestNodeMoveCommand
- TestPolicyCommand
- TestPolicyBrokenConfigCommand
- TestDERPVerifyEndpoint
- TestResolveMagicDNS
- TestResolveMagicDNSExtraRecordsPath
- TestValidateResolvConf
- TestDERPServerScenario
- TestDERPServerWebsocketScenario
- TestPingAllByIP
- TestPingAllByIPPublicDERP
- TestEphemeral
- TestEphemeralInAlternateTimezone
- TestEphemeral2006DeletedTooQuickly
- TestPingAllByHostname
- TestTaildrop
- TestUpdateHostnameFromClient
- TestExpireNode
- TestNodeOnlineStatus
- TestPingAllByIPManyUpDown
- Test2118DeletingOnlineNodePanics
- TestEnablingRoutes
- TestHASubnetRouterFailover
- TestEnableDisableAutoApprovedRoute
- TestAutoApprovedSubRoute2068
- TestSubnetRouteACL
- TestEnablingExitRoutes
- TestHeadscale
- TestCreateTailscale
- TestTailscaleNodesJoiningHeadcale
- TestSSHOneUserToAll
- TestSSHMultipleUsersAllToAll
- TestSSHNoSSHConfigured
- TestSSHIsBlockedInACL
- TestSSHUserOnlyIsolation
database: [postgres, sqlite]
env:
# Github does not allow us to access secrets in pull requests,
# so this env var is used to check if we have the secret or not.
# If we have the secrets, meaning we are running on push in a fork,
# there might be secrets available for more debugging.
# If TS_OAUTH_CLIENT_ID and TS_OAUTH_SECRET is set, then the job
# will join a debug tailscale network, set up SSH and a tmux session.
# The SSH will be configured to use the SSH key of the Github user
# that triggered the build.
HAS_TAILSCALE_SECRET: ${{ secrets.TS_OAUTH_CLIENT_ID }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Get changed files
id: changed-files
uses: dorny/paths-filter@v3
with:
filters: |
files:
- '*.nix'
- 'go.*'
- '**/*.go'
- 'integration_test/'
- 'config-example.yaml'
- name: Tailscale
if: ${{ env.HAS_TAILSCALE_SECRET }}
uses: tailscale/github-action@v2
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:gh
- name: Setup SSH server for Actor
if: ${{ env.HAS_TAILSCALE_SECRET }}
uses: alexellis/setup-sshd-actor@master
- uses: DeterminateSystems/nix-installer-action@main
if: steps.changed-files.outputs.files == 'true'
- uses: DeterminateSystems/magic-nix-cache-action@main
if: steps.changed-files.outputs.files == 'true'
- uses: satackey/action-docker-layer-caching@main
if: steps.changed-files.outputs.files == 'true'
continue-on-error: true
- name: Run Integration Test
uses: Wandalen/wretry.action@master
if: steps.changed-files.outputs.files == 'true'
env:
USE_POSTGRES: ${{ matrix.database == 'postgres' && '1' || '0' }}
with:
attempt_limit: 5
command: |
nix develop --command -- docker run \
--tty --rm \
--volume ~/.cache/hs-integration-go:/go \
--name headscale-test-suite \
--volume $PWD:$PWD -w $PWD/integration \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume $PWD/control_logs:/tmp/control \
--env HEADSCALE_INTEGRATION_POSTGRES=${{env.USE_POSTGRES}} \
--env HEADSCALE_EXPERIMENTAL_POLICY_V2=1 \
golang:1 \
go run gotest.tools/gotestsum@latest -- ./... \
-failfast \
-timeout 120m \
-parallel 1 \
-run "^${{ matrix.test }}$"
- uses: actions/upload-artifact@v4
if: always() && steps.changed-files.outputs.files == 'true'
with:
name: ${{ matrix.test }}-${{matrix.database}}-${{matrix.policy}}-logs
path: "control_logs/*.log"
- uses: actions/upload-artifact@v4
if: always() && steps.changed-files.outputs.files == 'true'
with:
name: ${{ matrix.test }}-${{matrix.database}}-${{matrix.policy}}-pprof
path: "control_logs/*.pprof.tar"
- name: Setup a blocking tmux session
if: ${{ env.HAS_TAILSCALE_SECRET }}
uses: alexellis/block-with-tmux-action@master
5 changes: 3 additions & 2 deletions .github/workflows/test-integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ jobs:
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume $PWD/control_logs:/tmp/control \
--env HEADSCALE_INTEGRATION_POSTGRES=${{env.USE_POSTGRES}} \
--env HEADSCALE_EXPERIMENTAL_POLICY_V2=0 \
golang:1 \
go run gotest.tools/gotestsum@latest -- ./... \
-failfast \
Expand All @@ -146,12 +147,12 @@ jobs:
- uses: actions/upload-artifact@v4
if: always() && steps.changed-files.outputs.files == 'true'
with:
name: ${{ matrix.test }}-${{matrix.database}}-logs
name: ${{ matrix.test }}-${{matrix.database}}-${{matrix.policy}}-logs
path: "control_logs/*.log"
- uses: actions/upload-artifact@v4
if: always() && steps.changed-files.outputs.files == 'true'
with:
name: ${{ matrix.test }}-${{matrix.database}}-pprof
name: ${{ matrix.test }}-${{matrix.database}}-${{matrix.policy}}-pprof
path: "control_logs/*.pprof.tar"
- name: Setup a blocking tmux session
if: ${{ env.HAS_TAILSCALE_SECRET }}
Expand Down
64 changes: 52 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

### BREAKING

Route internals have been rewritten, removing the dedicated route table in the database.
This was done to simplify the codebase, which had grown unnecessarily complex after
the routes were split into separate tables. The overhead of having to go via the database
and keeping the state in sync made the code very hard to reason about and prone to errors.
The majority of the route state is only relevant when headscale is running, and is now only
kept in memory.
As part of this, the CLI and API has been simplified to reflect the changes;
Route internals have been rewritten, removing the dedicated route table in the
database. This was done to simplify the codebase, which had grown unnecessarily
complex after the routes were split into separate tables. The overhead of having
to go via the database and keeping the state in sync made the code very hard to
reason about and prone to errors. The majority of the route state is only
relevant when headscale is running, and is now only kept in memory. As part of
this, the CLI and API has been simplified to reflect the changes;

```console
$ headscale nodes list-routes
Expand All @@ -27,15 +27,55 @@ ID | Hostname | Approved | Available | Serving
2 | ts-unstable-fq7ob4 | | 0.0.0.0/0, ::/0 |
```

Note that if an exit route is approved (0.0.0.0/0 or ::/0), both IPv4 and IPv6 will be approved.
Note that if an exit route is approved (0.0.0.0/0 or ::/0), both IPv4 and IPv6
will be approved.

- Route API and CLI has been removed [#2422](https://github.com/juanfont/headscale/pull/2422)
- Routes are now managed via the Node API [#2422](https://github.com/juanfont/headscale/pull/2422)
- Route API and CLI has been removed
[#2422](https://github.com/juanfont/headscale/pull/2422)
- Routes are now managed via the Node API
[#2422](https://github.com/juanfont/headscale/pull/2422)

### Experimental Policy v2

This release introduces a new experimental version of Headscales policy
implementation. In this context, experimental means that the feature is not yet
fully tested and may contain bugs or unexpected behavior and that we are still
experimenting with how the final interface/behavior will be.

#### Breaking changes

- The policy is validated and "resolved" when loading, providing errors for
invalid rules and conditions.
- Previously this was done as a mix between load and runtime (when it was
applied to a node).
- This means that when you convert the first time, what was previously a
policy that loaded, but failed at runtime, will now fail at load time.
- Error messages should be more descriptive and informative.
- There is still work to be here, but it is already improved with "typing"
(e.g. only Users can be put in Groups)
- All users must contain an `@` character.
- If your user naturally contains and `@`, like an email, this will just work.
- If its based on usernames, or other identifiers not containing an `@`, an
`@` should be appended at the end. For example, if your user is `john`, it
must be written as `john@` in the policy.

#### Current state

The new policy is passing all tests, both integration and unit tests. This does
not mean it is perfect, but it is a good start. Corner cases that is currently
working in v1 and not tested might be broken in v2 (and vice versa).

**We do need help testing this code**, and we think that most of the user facing
API will not really change. We are not sure yet when this code will replace v1,
but we are confident that it will, and all new changes and fixes will be made
towards this code.

The new policy can be used by setting the environment variable
`HEADSCALE_EXPERIMENTAL_POLICY_V2` to `1`.

### Changes

- Use Go 1.24
[#2427](https://github.com/juanfont/headscale/pull/2427)
- Use Go 1.24 [#2427](https://github.com/juanfont/headscale/pull/2427)
- `oidc.map_legacy_users` and `oidc.strip_email_domain` has been removed
[#2411](https://github.com/juanfont/headscale/pull/2411)
- Add more information to `/debug` endpoint
Expand Down
21 changes: 16 additions & 5 deletions hscontrol/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,14 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {

var magicDNSDomains []dnsname.FQDN
if cfg.PrefixV4 != nil {
magicDNSDomains = append(magicDNSDomains, util.GenerateIPv4DNSRootDomain(*cfg.PrefixV4)...)
magicDNSDomains = append(
magicDNSDomains,
util.GenerateIPv4DNSRootDomain(*cfg.PrefixV4)...)
}
if cfg.PrefixV6 != nil {
magicDNSDomains = append(magicDNSDomains, util.GenerateIPv6DNSRootDomain(*cfg.PrefixV6)...)
magicDNSDomains = append(
magicDNSDomains,
util.GenerateIPv6DNSRootDomain(*cfg.PrefixV6)...)
}

// we might have routes already from Split DNS
Expand Down Expand Up @@ -459,11 +463,13 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *mux.Router {
router := mux.NewRouter()
router.Use(prometheusMiddleware)

router.HandleFunc(ts2021UpgradePath, h.NoiseUpgradeHandler).Methods(http.MethodPost, http.MethodGet)
router.HandleFunc(ts2021UpgradePath, h.NoiseUpgradeHandler).
Methods(http.MethodPost, http.MethodGet)

router.HandleFunc("/health", h.HealthHandler).Methods(http.MethodGet)
router.HandleFunc("/key", h.KeyHandler).Methods(http.MethodGet)
router.HandleFunc("/register/{registration_id}", h.authProvider.RegisterHandler).Methods(http.MethodGet)
router.HandleFunc("/register/{registration_id}", h.authProvider.RegisterHandler).
Methods(http.MethodGet)

if provider, ok := h.authProvider.(*AuthProviderOIDC); ok {
router.HandleFunc("/oidc/callback", provider.OIDCCallbackHandler).Methods(http.MethodGet)
Expand Down Expand Up @@ -523,7 +529,11 @@ func usersChangedHook(db *db.HSDatabase, polMan policy.PolicyManager, notif *not
// Maybe we should attempt a new in memory state and not go via the DB?
// Maybe this should be implemented as an event bus?
// A bool is returned indicating if a full update was sent to all nodes
func nodesChangedHook(db *db.HSDatabase, polMan policy.PolicyManager, notif *notifier.Notifier) (bool, error) {
func nodesChangedHook(
db *db.HSDatabase,
polMan policy.PolicyManager,
notif *notifier.Notifier,
) (bool, error) {
nodes, err := db.ListNodes()
if err != nil {
return false, err
Expand Down Expand Up @@ -1143,6 +1153,7 @@ func (h *Headscale) loadPolicyManager() error {
errOut = fmt.Errorf("creating policy manager: %w", err)
return
}
log.Info().Msgf("Using policy manager version: %d", h.polMan.Version())

if len(nodes) > 0 {
_, err = h.polMan.SSHPolicy(nodes[0])
Expand Down
3 changes: 2 additions & 1 deletion hscontrol/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"tailscale.com/net/tsaddr"
"tailscale.com/util/set"
"zgo.at/zcache/v2"
)
Expand Down Expand Up @@ -655,7 +656,7 @@ AND auth_key_id NOT IN (
}

for nodeID, routes := range nodeRoutes {
slices.SortFunc(routes, util.ComparePrefix)
tsaddr.SortPrefixes(routes)
slices.Compact(routes)

data, err := json.Marshal(routes)
Expand Down
Loading
Loading