Compare commits

...

171 Commits

Author SHA1 Message Date
Johan Siebens 280c626704 fix incorrect dns configuration examples 2025-05-28 21:39:25 +02:00
Johan Siebens 57e8eb3a25 feat: add support for external dns plugins 2025-05-24 11:01:05 +02:00
Johan Siebens f8b0eceae7 fix: add missing tags 2025-05-23 08:25:37 +02:00
Johan Siebens 924ddf1b36 minor updates 2025-05-07 21:44:42 +02:00
Johan Siebens 65e446e126 fix incorrect env variables usage 2025-05-07 21:35:35 +02:00
Johan Siebens 8fe4342571 update docs and install script 2025-05-07 21:31:40 +02:00
Johan Siebens d5a5a924ca update docs 2025-04-29 07:24:54 +02:00
Johan Siebens 442d794189 update docs 2025-04-28 13:38:09 +02:00
Johan Siebens 9483662395 update docs 2025-04-28 13:30:13 +02:00
Johan Siebens 5b9da83c30 update docs 2025-04-28 13:27:08 +02:00
Johan Siebens 776e3de7d8 updates docs 2025-04-28 11:48:45 +02:00
Johan Siebens a0e1fcb4aa add configuration page 2025-04-28 11:47:45 +02:00
Johan Siebens 814335d703 fix: update auto-approved advertised routes when set after registration 2025-04-28 11:14:36 +02:00
Johan Siebens 644b99b70f chore: update docs 2025-04-27 11:00:05 +02:00
Johan Siebens 1ab135aa9a fix typo 2025-04-27 10:40:39 +02:00
Johan Siebens 4c31c71593 chore: update deps 2025-04-27 10:38:23 +02:00
Johan Siebens d5dd7b1c9d chore: upgrade go toolchain 2025-04-27 10:38:23 +02:00
Johan Siebens 2116b38ae5 update docs 2025-04-27 09:21:50 +02:00
Johan Siebens 0127b027f6 updates 2025-04-26 22:02:37 +02:00
Johan Siebens a4ae50f20c updates 2025-04-26 21:58:06 +02:00
Johan Siebens e1f3ad61fb chore: updates docs 2025-04-26 21:54:11 +02:00
Johan Siebens a2fd56be89 fix: nil pointer when sonyflake is not properly configured 2025-04-26 09:48:57 +02:00
Johan Siebens 828e0c920b chore: use yaml-to-json library, giving better support for configuring third-party libs like libdns providers 2025-03-16 11:03:39 +01:00
Johan Siebens 978b0ecf4f feat: add environment variable substition in configuration, remove implicit use of env variables 2025-03-16 11:03:39 +01:00
Johan Siebens c1c708269c fix: improve session management and update channels, avoiding potential deadlocks 2025-03-06 16:35:59 +01:00
Johan Siebens ed3e1eb54a feat: add /machine/update-health handler 2025-02-16 13:32:50 +01:00
Johan Siebens 4a3b5399e6 chore: remove print lines 2025-02-16 11:01:20 +01:00
Johan Siebens 28c5ff2570 feat: add command to set name of a machine 2025-02-15 15:17:37 +01:00
Johan Siebens 48bd29beba chore: bump base image 2025-02-15 07:44:36 +01:00
Johan Siebens eb46fa12ec chore: remove stale issues workflow 2025-02-15 07:42:11 +01:00
Johan Siebens 99aececab2 chore(deps): upgrade tailscale dependency 2025-02-11 16:24:09 +01:00
Johan Siebens 8e0cc33fd4 chore: upgrade go toolchain 2025-02-11 12:02:29 +01:00
Johan Siebens 4394d44cbd fix: add support for autgroup:member when validating node attributes
Signed-off-by: Johan Siebens <johan.siebens@gmail.com>
2024-11-23 08:24:09 +01:00
Johan Siebens d44832ea78 chore: update cosign installer 2024-06-03 08:35:49 +02:00
Johan Siebens cd9d7655d1 fix: remove import of tailscale ssh package 2024-06-03 08:06:38 +02:00
Johan Siebens 43c27a1340 feat: add ssh recorder implementation 2024-06-03 07:24:43 +02:00
Johan Siebens 4bce1c33b8 feat: ssh recording 2024-06-03 07:24:43 +02:00
Johan Siebens 78825d4e05 chore: bump base image 2024-05-29 08:44:43 +02:00
Johan Siebens 41de33deab feat: add support for autogroup:danger-all 2024-05-29 08:41:48 +02:00
Johan Siebens eadd42b19a fix: expand src wildcard alias to peer ip addresses 2024-05-29 08:30:42 +02:00
Johan Siebens 3d21630bf3 fix: notify others when node is online 2024-04-27 09:21:40 +02:00
Johan Siebens 5adec31963 fix: handle relative name and zones correctly 2024-04-26 14:33:15 +02:00
Johan Siebens 0b5f54c7d3 chore(docs): update docs 2024-04-26 11:22:06 +02:00
Johan Siebens 128a184a59 feat: only support tailscale clients >= 1.48 2024-04-26 09:34:01 +02:00
Johan Siebens 3f942b99df chore: use correct trivy tag 2024-04-26 08:48:53 +02:00
Johan Siebens ea62dbaf4e chore: upgrade some actions 2024-04-26 08:45:10 +02:00
Johan Siebens 4835a16a7c chore(deps): upgrade dependencies 2024-04-26 08:43:06 +02:00
Johan Siebens d1e8346e93 chore: fix buf linting 2024-04-26 08:16:52 +02:00
Johan Siebens 3758e0cde2 chore: fix for codeql 2024-04-26 08:07:33 +02:00
Johan Siebens ab8ee08f19 chore: upgrade integration test targets 2024-04-26 08:06:31 +02:00
Johan Siebens 48a2c78c05 chore(docs): update reference 2024-03-15 09:24:09 +01:00
Johan Siebens e7370d98a3 chore: remove cyclic imports after merges 2024-03-15 08:53:57 +01:00
Johan Siebens 2811465206 chore: web listener to listener 2024-03-15 08:49:35 +01:00
Johan Siebens 6173621730 feat: use hujson as data format for ACL and IAM policy 2024-03-15 08:47:06 +01:00
Johan Siebens a1debdffb8 feat: use env variable for setting a default tailnet id when using a system admin key 2024-03-15 08:42:05 +01:00
Johan Siebens 42702682c9 chore: fix failing tests 2024-03-03 18:00:17 +01:00
Johan Siebens 248b75cd77 feat: embedded derp 2024-03-02 06:58:36 +01:00
Johan Siebens 27c6a1fa12 chore: add latest version to test matrix 2024-02-29 08:01:10 +01:00
Johan Siebens 62a7290e3d chore: run tests with tls using self-signed certificate 2024-02-29 08:01:10 +01:00
Johan Siebens 9a60430949 chore: update go version 2024-02-24 10:57:53 +01:00
Johan Siebens d72ea03d9d improvement: change http(s) listener to web listener addr and a public web addr 2024-02-23 13:27:20 +01:00
Johan Siebens 94d9168eab chore: set correct acme issuer logger 2024-02-23 11:16:50 +01:00
Johan Siebens 72ed4c66e3 chore: update readme 2024-02-21 08:26:52 +01:00
Johan Siebens 0ecd0050d0 improvement: graceful shutdown 2024-02-21 08:08:36 +01:00
Johan Siebens f1285fdc7e chore: update readme 2024-02-21 08:07:21 +01:00
Johan Siebens 1ffafeea79 improvement: don't save tailnet and don't signal change when nothing is updated 2024-02-19 10:22:19 +01:00
Johan Siebens 68127b9a98 improvement: update layout 2024-02-18 08:01:04 +01:00
Johan Siebens 69ce610579 chore: http errorlog to zap 2024-02-16 09:19:48 +01:00
Johan Siebens afe587cb03 chore(docs): update docs 2024-02-15 16:01:12 +01:00
Johan Siebens 91c62ee892 fix: correct check if dns provider is set 2024-02-13 14:25:03 +01:00
Johan Siebens 7aeed60fe1 chore: refactor domain repository interfaces 2024-02-13 11:11:34 +01:00
Johan Siebens e39eb5824b improvement: set last authentication timestamp on user and use it to check ssh access 2024-02-12 21:05:40 +01:00
Johan Siebens 7c2d5f723a feat: add pprof endpoints 2024-02-12 13:09:45 +01:00
Johan Siebens 84d29fda34 improvement: remove usage of deprecated echo prometheus integration 2024-02-12 11:04:07 +01:00
Johan Siebens 41b64eed71 fix: expired peer missing in peer list 2024-02-10 15:36:28 +01:00
Johan Siebens 0eef9faf86 fix: show correct number of peers after switching accounts 2024-02-10 14:51:15 +01:00
Johan Siebens 271d99a3ce chore(tests): add flag to print ionscale logs 2024-02-10 13:43:33 +01:00
Johan Siebens cf8b2be0e8 chore: omit associations by default 2024-02-10 13:35:43 +01:00
Johan Siebens b098562988 fix: log in with different use should create new machine entry 2024-02-10 10:04:44 +01:00
Johan Siebens 46cce89e0e chore: go mod tidy 2024-02-07 08:54:58 +01:00
Johan Siebens 128ed22bde feat: add support for search domains in dns config 2024-02-07 08:13:44 +01:00
Johan Siebens 5d1ac326ea fix: check if tailnet with name already exists 2024-02-06 21:39:30 +01:00
Johan Siebens 7eb808c71c fix: add ssh rules to default acl policy 2024-02-06 21:31:10 +01:00
Johan Siebens d8f0492940 feat: add device aliases 2024-02-06 08:03:50 +01:00
Johan Siebens b8b1075389 chore(deps): upgrade some dependencies 2024-02-06 07:41:28 +01:00
Johan Siebens 9f3a6bbcec feat: save tokens for multiple ionscale servers 2024-02-05 17:21:21 +01:00
Johan Siebens cce0fd08b0 chore: refactor target and tailnet selection in commands 2024-02-05 17:21:21 +01:00
Johan Siebens 58634fc98e chore: go mod tidy 2024-02-04 16:43:56 +01:00
Johan Siebens 280ee7e1b6 feat: validate iam policy filters 2024-02-04 16:42:41 +01:00
Johan Siebens b8c752d04a fix: use default and additional scopes correctly 2024-02-03 10:44:36 +01:00
Johan Siebens dfd2fe9fdd chore: bump version 2024-02-03 09:43:42 +01:00
Johan Siebens 25203d3cca fix: little layout issue 2024-02-03 09:42:38 +01:00
Johan Siebens 0f54539302 chore: bump base image 2024-02-03 09:12:19 +01:00
Johan Siebens dea60272b7 fix: cli also accepts IONSCALE_KEYS_SYSTEM_ADMIN_KEY env variable 2024-02-03 09:06:43 +01:00
Johan Siebens 5e43014a09 feat: remove inactive emphemeral machines when server starts; rename reaper to worker 2024-02-03 09:04:33 +01:00
Johan Siebens 9748955f18 fix: some small logging fixes 2024-02-02 08:57:23 +01:00
Johan Siebens 44b6b20361 feat: store acme certificates in db 2024-02-01 15:29:23 +01:00
Johan Siebens cbde00c9f5 chore: replace duplicate template code with templ 2024-01-26 13:27:35 +01:00
Johan Siebens 8f2c198bfe fix: avoid peer lookup if not needed 2024-01-25 08:58:18 +01:00
Johan Siebens 8f998b05f7 feat: acl grants 2024-01-25 07:40:15 +01:00
Johan Siebens 3fccde2932 feat: also accept hujson files 2024-01-20 10:44:20 +01:00
Johan Siebens 7fa31bdf1f feat: add support for protocol in acl rules 2024-01-19 10:21:30 +01:00
Johan Siebens 980ab1bc46 fix: send empty PacketFilter when no rules match 2024-01-19 09:56:28 +01:00
Johan Siebens 123ca99665 fix: mark query feature request as incomplete when necessary 2024-01-19 07:59:22 +01:00
Johan Siebens 0c5e586cf9 chore: upgrade actions 2024-01-15 16:06:30 +01:00
Johan Siebens 79bc3bffb1 chore: upgrade codeql actions 2024-01-15 16:03:48 +01:00
Johan Siebens 452c5ee516 chore: add workflow to label and close stale issues 2024-01-15 16:01:06 +01:00
Johan Siebens c1ea283e6d fix: incorrect splitting of alias and port ranges 2024-01-15 12:14:53 +01:00
Johan Siebens 6a5d44882a chore(deps): use renamed mockoidc module 2024-01-11 09:25:19 +01:00
Johan Siebens cbcbd61c3e feat: remove support for non-noise clients 2024-01-10 11:05:07 +01:00
Johan Siebens b083e2631a chore(deps): upgrade dependencies 2024-01-10 08:20:44 +01:00
Johan Siebens 4587ed8eaa chore: restructure test setup and add some initial web login flow tests 2024-01-10 08:03:22 +01:00
Johan Siebens 3118d2e573 chore: fix integration tests 2024-01-09 10:00:02 +00:00
Johan Siebens 7e1d90590d chore: take binaries from official docker image 2024-01-09 09:22:37 +00:00
Johan Siebens 1b66b1e9be fix: incorrect index 2024-01-06 16:48:43 +01:00
Johan Siebens 35e13a0698 chore: add idea and git folders 2024-01-05 14:58:15 +01:00
Johan Siebens 951d0f299e chore: add simple acl test 2024-01-05 10:45:02 +01:00
Johan Siebens d10a022f29 chore: use require and asserts 2024-01-05 10:32:54 +01:00
Johan Siebens 9b5f045849 feat: add support for node attributes 2024-01-05 10:03:09 +01:00
Johan Siebens 8a3f47490e chore: capmap vs capabilities 2024-01-04 17:02:11 +01:00
Johan Siebens c76c2f16dd chore: upgrade to latest tailscale version 2024-01-04 16:34:47 +01:00
Johan Siebens dd2e783d8e chore: ignore own machine id when notifying an update 2024-01-04 10:09:56 +01:00
Johan Siebens 473c3370ce chore: move mapping logic to seperate struct 2024-01-04 09:23:35 +01:00
Johan Siebens d6cc55cf5b chore: remove unused method 2024-01-03 08:46:01 +01:00
Johan Siebens 9808860412 feat: add support for 'always' value in ssh check period 2024-01-02 14:36:04 +01:00
Johan Siebens 2bc03b895b fix: add autogroup:member checks in ssh policies 2024-01-02 13:58:58 +01:00
Johan Siebens 54fa423acd feat: add support for autogroup:tagged 2024-01-02 09:32:12 +01:00
Johan Siebens a303de71ee feat: add support for autogroup:member 2024-01-02 09:24:08 +01:00
Johan Siebens cdbecf04fc chore: improve test setup 2023-12-31 12:54:56 +01:00
Johan Siebens 75b58d0784 feat: add query feature endpoint for 'serve' support 2023-12-29 16:02:20 +01:00
Johan Siebens 038c0afa8b fix: add unique constraint to index 2023-12-29 09:51:58 +01:00
Johan Siebens d9fafdcfd2 fix: add missing https capability 2023-12-28 11:49:49 +01:00
Johan Siebens 9b8782cccf fix: issue when enabling/disabling https certs 2023-12-28 11:33:39 +01:00
Johan Siebens ea658a0e81 chore(deps): upgrade tailscale deps 2023-12-28 11:10:59 +01:00
Johan Siebens e31ce67f84 feat: add support for ssh check periods 2023-12-28 08:34:25 +01:00
Johan Siebens d5ca503318 chore: generate with recent tools 2023-12-28 07:54:28 +01:00
Johan Siebens 4cab4dfb9a chore: run test with latest tailscale version 2023-12-27 09:28:27 +01:00
Johan Siebens 515f441dae chore: bump to latest version 2023-12-27 09:28:10 +01:00
Johan Siebens 9ac4c85c99 feat: add version column to machines list 2023-12-27 09:27:41 +01:00
Johan Siebens 60a2faec4a chore(ci): add latest release versions 2023-12-23 11:17:55 +01:00
Johan Siebens 339b9cfd37 fix: lazy load snowflake id generator 2023-12-23 11:16:10 +01:00
Johan Siebens d0eac84271 chore(deps): upgrade some dependencies 2023-12-23 08:48:16 +01:00
Johan Siebens f193afa146 chore: update base image 2023-12-23 07:48:30 +01:00
Johan Siebens cf67f6cf64 chore: update workflows and goreleaser 2023-12-23 07:29:58 +01:00
Johan Siebens 1ac3aa36ba chore(deps): upgrade tailscale dependency 2023-07-21 08:20:51 +02:00
Johan Siebens 9fd4e5fee4 fix: log error when starting server fails 2023-04-15 08:26:08 +02:00
Johan Siebens 326860c941 fix: panic when user is not authorized 2023-04-08 09:56:37 +02:00
Johan Siebens 4ba540cb2c chore: replace hclog with zap 2023-03-19 10:53:54 +01:00
Johan Siebens 3577b8b46e chore(deps): upgrade dependencies 2023-03-12 08:38:18 +01:00
Johan Siebens f24f0973fe chore(deps): golang 1.20 2023-03-12 08:26:35 +01:00
Johan Siebens 12cad15a4e chore(deps): upgrade tailscale 2023-03-12 08:26:13 +01:00
Johan Siebens d5c3c699dd chore(docs): fix typo 2023-03-11 08:53:54 +01:00
Johan Siebens b3b21be50d chore(docs): fix incorrect acme_enabled property 2023-03-11 08:41:11 +01:00
Johan Siebens 051650ae4e chore: upgrade to golang 1.20 2023-03-08 07:46:58 +01:00
Johan Siebens 2fc79ee0a1 chore(deps): replace coral with latest cobra 2023-03-08 07:43:03 +01:00
Johan Siebens b7b3796ae6 chore: update base image 2023-03-08 07:40:48 +01:00
Johan Siebens b0074152d1 chore: add tests with new 1.36 version 2023-01-28 19:36:27 +01:00
Johan Siebens 4550bdbf2a fix: set default ACL and IAM policy if not provided 2023-01-28 19:28:51 +01:00
Johan Siebens d32ece6304 feat: create and update tailnets with all properties 2023-01-07 08:20:35 +01:00
Johan Siebens ef325dd936 docs: update to latest version 2023-01-05 08:49:06 +01:00
Johan Siebens 9a55d67c7e chore(deps): upgrade setup-go action 2023-01-05 08:22:26 +01:00
Johan Siebens cbbaa31580 fix: use stdencoding instead of rawstdencoding 2023-01-03 08:13:06 +01:00
Johan Siebens 35c46eb2ec chore: fix workflow file 2023-01-02 14:10:10 +01:00
Johan Siebens d6a564b7a9 chore: some initial integration tests 2023-01-02 14:07:38 +01:00
Johan Siebens 527fb34560 fix: use smallzstd and sync pool, slightly improving performance 2023-01-01 12:08:25 +01:00
Johan Siebens 805a516626 fix: avoid double user entries 2023-01-01 09:43:14 +01:00
Johan Siebens 0dbc81d50f fix: send exit node prefixes when enabled 2023-01-01 08:10:31 +01:00
Johan Siebens 40cc7b5648 fix: send same user for all tagged devices, reducing mapresponse payload when having many tagged devices 2022-12-31 08:26:30 +01:00
Johan Siebens b62db084d1 feat: add config to tweak sql connection pool 2022-12-30 09:28:50 +01:00
Johan Siebens df23c178f9 feat: add gorm prometheus metrics 2022-12-30 09:07:29 +01:00
Johan Siebens 9f7263abd5 fix: add machines indeces and use gorm take instead of first 2022-12-27 11:14:53 +01:00
191 changed files with 14497 additions and 9140 deletions
+4
View File
@@ -0,0 +1,4 @@
.git
.idea
tests
!tests/config/ca.pem
+6 -5
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Buf
uses: bufbuild/buf-setup-action@v1
- name: Buf Lint
@@ -25,12 +25,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v4
with:
go-version: 1.19
go-version-file: 'go.mod'
cache: true
- name: Build
run: |
go test ./...
go test -v -short ./...
go build cmd/ionscale/main.go
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- uses: actions/setup-python@v2
with:
python-version: 3.x
+38
View File
@@ -0,0 +1,38 @@
name: Integration Tests
on:
workflow_dispatch: {}
pull_request:
branches:
- main
jobs:
integration:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ts_version:
- "v1.80"
- "v1.78"
- "v1.76"
- "v1.74"
- "v1.72"
- "v1.70"
- "v1.68"
- "v1.66"
env:
IONSCALE_TESTS_TS_TARGET_VERSION: ${{ matrix.ts_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version-file: 'go.mod'
cache: true
- name: Run Integration Tests
run: |
go test -v ./tests
-42
View File
@@ -1,42 +0,0 @@
name: nightly
on: workflow_dispatch
permissions:
contents: write
packages: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
env:
DOCKER_CLI_EXPERIMENTAL: "enabled"
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Docker Login
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.19
- name: Install cosign
uses: sigstore/cosign-installer@v2.8.1
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
distribution: goreleaser-pro
version: latest
args: release --nightly --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
+9 -9
View File
@@ -3,7 +3,7 @@ name: release
on:
push:
tags:
- '*'
- 'v*'
permissions:
contents: write
@@ -17,7 +17,7 @@ jobs:
DOCKER_CLI_EXPERIMENTAL: "enabled"
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
@@ -29,17 +29,17 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v4
with:
go-version: 1.19
go-version-file: 'go.mod'
cache: true
- name: Install cosign
uses: sigstore/cosign-installer@v2.8.1
uses: sigstore/cosign-installer@v3
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser-pro
distribution: goreleaser
version: latest
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6 -6
View File
@@ -15,15 +15,15 @@ jobs:
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: go
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3
trivy:
name: Trivy
@@ -33,16 +33,16 @@ jobs:
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@0.19.0
with:
image-ref: ghcr.io/jsiebens/ionscale:latest
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v1
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
+3 -4
View File
@@ -1,8 +1,5 @@
project_name: ionscale
nightly:
name_template: '{{ incminor .Version }}-dev'
before:
hooks:
- go mod tidy
@@ -44,7 +41,7 @@ docker_manifests:
image_templates:
- ghcr.io/jsiebens/{{ .ProjectName }}:{{ .Version }}-amd64
- ghcr.io/jsiebens/{{ .ProjectName }}:{{ .Version }}-arm64
- name_template: ghcr.io/jsiebens/{{ .ProjectName }}:{{ if .IsNightly }}dev{{ else }}latest{{ end }}
- name_template: ghcr.io/jsiebens/{{ .ProjectName }}:latest
image_templates:
- ghcr.io/jsiebens/{{ .ProjectName }}:{{ .Version }}-amd64
- ghcr.io/jsiebens/{{ .ProjectName }}:{{ .Version }}-arm64
@@ -59,6 +56,7 @@ signs:
- '--output-certificate=${certificate}'
- '--output-signature=${signature}'
- '${artifact}'
- '--yes'
artifacts: checksum
docker_signs:
@@ -70,6 +68,7 @@ docker_signs:
args:
- sign
- '${artifact}'
- '--yes'
archives:
- format: binary
+1 -1
View File
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM:-linux/amd64} alpine:3.16.2
FROM --platform=${BUILDPLATFORM:-linux/amd64} alpine:3.21.3
COPY ionscale /usr/local/bin/ionscale
+2
View File
@@ -1,10 +1,12 @@
init:
go install github.com/a-h/templ/cmd/templ@latest
go install github.com/bufbuild/buf/cmd/buf@latest
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install github.com/bufbuild/connect-go/cmd/protoc-gen-connect-go@latest
generate:
templ generate
buf generate proto
format:
+12 -2
View File
@@ -14,19 +14,29 @@ While the Tailscale software running on each node is open source, their centrali
_ionscale_ aims to implement such lightweight, open source alternative Tailscale control server.
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/jsiebens/ionscale/build.yaml)
![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/jsiebens/ionscale?label=go)
![Go Report Card](https://goreportcard.com/badge/github.com/jsiebens/ionscale)
![GitHub All Releases](https://img.shields.io/github/downloads/jsiebens/ionscale/total)
![GitHub Release](https://img.shields.io/github/v/release/jsiebens/ionscale)
⭐ If you find _ionscale_ useful, please consider giving it a star on GitHub, or [drop a note](https://github.com/jsiebens/ionscale/discussions/new?category=show-and-tell) on how you are using _ionscale_.
## Features
- multi [tailnet](https://tailscale.com/kb/1136/tailnet/) support
- multi user support
- OIDC integration (not required, although recommended)
- [Auth keys](https://tailscale.com/kb/1085/auth-keys/)
- [Access control list](https://tailscale.com/kb/1018/acls/)
- [Access control list](https://tailscale.com/kb/1018/acls/) (with support for `autogroups`, `tagOwners`, `autoApprovers`, `nodeAttrs`, `grants` ...)
- [DNS](https://tailscale.com/kb/1054/dns/)
- nameservers
- Split DNS
- MagicDNS
- MagicDNS
- [Subnet routers](https://tailscale.com/kb/1019/subnets) and [Exit Nodes](https://tailscale.com/kb/1103/exit-nodes)
- [HTTPS Certs](https://tailscale.com/kb/1153/enabling-https/)
- [Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh/)
- [Tailscale Serve](https://tailscale.com/kb/1312/serve)
- [Service collection](https://tailscale.com/kb/1100/services/)
- [Taildrop](https://tailscale.com/kb/1106/taildrop/)
+240 -126
View File
@@ -1,153 +1,267 @@
module github.com/jsiebens/ionscale
go 1.19
go 1.24.2
require (
github.com/99designs/keyring v1.2.2
github.com/a-h/templ v0.3.857
github.com/apparentlymart/go-cidr v1.1.0
github.com/bufbuild/connect-go v1.0.0
github.com/caarlos0/env/v6 v6.10.1
github.com/caddyserver/certmagic v0.17.1
github.com/coreos/go-oidc/v3 v3.3.0
github.com/glebarez/sqlite v1.5.0
github.com/go-gormigrate/gormigrate/v2 v2.0.2
github.com/golang-jwt/jwt/v4 v4.4.2
github.com/hashicorp/go-bexpr v0.1.11
github.com/hashicorp/go-hclog v1.3.0
github.com/bufbuild/connect-go v1.10.0
github.com/caddyserver/certmagic v0.20.0
github.com/coreos/go-oidc/v3 v3.14.1
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0
github.com/glebarez/sqlite v1.11.0
github.com/go-gormigrate/gormigrate/v2 v2.1.4
github.com/go-jose/go-jose/v3 v3.0.4
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/hashicorp/go-bexpr v0.1.14
github.com/hashicorp/go-getter v1.7.8
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-multierror v1.1.1
github.com/imdario/mergo v0.3.12
github.com/hashicorp/go-plugin v1.6.3
github.com/jsiebens/go-edit v0.1.0
github.com/klauspost/compress v1.15.9
github.com/labstack/echo-contrib v0.13.0
github.com/labstack/echo/v4 v4.9.0
github.com/libdns/azure v0.2.0
github.com/libdns/cloudflare v0.1.0
github.com/libdns/digitalocean v0.0.0-20220518195853-a541bc8aa80f
github.com/libdns/googleclouddns v1.0.2
github.com/libdns/libdns v0.2.1
github.com/libdns/route53 v1.2.2
github.com/jsiebens/libdns-plugin v0.1.0
github.com/jsiebens/mockoidc v0.1.0-rc2
github.com/klauspost/compress v1.18.0
github.com/labstack/echo-contrib v0.17.3
github.com/labstack/echo/v4 v4.13.3
github.com/libdns/azure v0.4.0
github.com/libdns/cloudflare v0.1.1
github.com/libdns/digitalocean v0.0.0-20230728223659-4f9064657aea
github.com/libdns/googleclouddns v1.1.0
github.com/libdns/libdns v0.2.3
github.com/libdns/route53 v1.3.3
github.com/mitchellh/go-homedir v1.1.0
github.com/mitchellh/pointerstructure v1.2.1
github.com/mr-tron/base58 v1.2.0
github.com/muesli/coral v1.0.0
github.com/nleeper/goment v1.4.4
github.com/prometheus/client_golang v1.13.0
github.com/rodaine/table v1.0.1
github.com/sony/sonyflake v1.1.0
github.com/stretchr/testify v1.8.0
github.com/xhit/go-str2duration/v2 v2.0.0
golang.org/x/crypto v0.1.0
golang.org/x/net v0.4.0
golang.org/x/oauth2 v0.0.0-20221006150949-b44042a4b9c1
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
google.golang.org/protobuf v1.28.1
gopkg.in/square/go-jose.v2 v2.6.0
github.com/ory/dockertest/v3 v3.12.0
github.com/prometheus/client_golang v1.22.0
github.com/puzpuzpuz/xsync/v3 v3.5.1
github.com/rodaine/table v1.3.0
github.com/sony/sonyflake v1.2.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
github.com/travisjeffery/certmagic-sqlstorage v1.1.1
github.com/xhit/go-str2duration/v2 v2.1.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.38.0
golang.org/x/net v0.40.0
golang.org/x/oauth2 v0.29.0
golang.org/x/sync v0.14.0
google.golang.org/protobuf v1.36.6
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.4.4
gorm.io/gorm v1.24.0
inet.af/netaddr v0.0.0-20220811202034-502d2d690317
tailscale.com v1.32.0
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.26.0
gorm.io/plugin/prometheus v0.1.0
inet.af/netaddr v0.0.0-20230525184311-b8eac61e914a
sigs.k8s.io/yaml v1.4.0
tailscale.com v1.80.0
)
require (
cloud.google.com/go/compute v1.7.0 // indirect
github.com/Azure/azure-sdk-for-go v52.4.0+incompatible // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest v0.11.17 // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.11 // indirect
github.com/Azure/go-autorest/autorest/azure/auth v0.5.7 // indirect
github.com/Azure/go-autorest/autorest/azure/cli v0.4.2 // indirect
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
github.com/Azure/go-autorest/logger v0.2.0 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
github.com/aws/aws-sdk-go-v2 v1.11.2 // indirect
github.com/aws/aws-sdk-go-v2/config v1.11.0 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.6.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.8.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.0.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.5.2 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.6.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.11.1 // indirect
github.com/aws/smithy-go v1.9.0 // indirect
cel.dev/expr v0.23.1 // indirect
cloud.google.com/go v0.120.1 // indirect
cloud.google.com/go/auth v0.16.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
cloud.google.com/go/storage v1.52.0 // indirect
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.6.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/aws/aws-sdk-go v1.55.7 // indirect
github.com/aws/aws-sdk-go-v2 v1.26.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.11 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.40.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 // indirect
github.com/aws/smithy-go v1.20.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/digitalocean/godo v1.41.0 // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/form3tech-oss/jwt-go v3.2.2+incompatible // indirect
github.com/glebarez/go-sqlite v1.19.2 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/bits-and-blooms/bitset v1.13.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/containerd/continuity v0.4.5 // indirect
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
github.com/danieljoos/wincred v1.2.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dblohm7/wingoes v0.0.0-20240123200102-b75a8a7d7eb0 // indirect
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect
github.com/digitalocean/godo v1.113.0 // indirect
github.com/docker/cli v27.4.1+incompatible // indirect
github.com/docker/docker v27.4.1+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/dvsekhvalnov/jose2go v1.7.0 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gaissmai/bart v0.11.1 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.1.0 // indirect
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa // indirect
github.com/googleapis/gax-go/v2 v2.4.0 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.13.0 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-safetemp v1.0.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/illarion/gonotify/v2 v2.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.12.0 // indirect
github.com/jackc/pgx/v4 v4.17.2 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/native v1.0.0 // indirect
github.com/jsimonetti/rtnetlink v1.2.2 // indirect
github.com/klauspost/cpuid/v2 v2.1.1 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/lib/pq v1.10.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mdlayher/netlink v1.6.0 // indirect
github.com/mdlayher/socket v0.2.3 // indirect
github.com/mholt/acmez v1.0.4 // indirect
github.com/miekg/dns v1.1.50 // indirect
github.com/jsimonetti/rtnetlink v1.4.1 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/sdnotify v1.0.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/mholt/acmez v1.2.0 // indirect
github.com/miekg/dns v1.1.59 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tkuchiki/go-timezone v0.2.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/mtibben/percent v0.2.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/runc v1.2.3 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus-community/pro-bing v0.4.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/safchain/ethtool v0.3.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 // indirect
github.com/tkuchiki/go-timezone v0.2.3 // indirect
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
go.opencensus.io v0.23.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
go.uber.org/zap v1.23.0 // indirect
go4.org/intern v0.0.0-20220617035311-6925f38cc365 // indirect
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
golang.org/x/tools v0.1.12 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/zeebo/blake3 v0.2.3 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/sdk v1.35.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go4.org/intern v0.0.0-20230525184215-6c62f75575cb // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.29.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
google.golang.org/api v0.84.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90 // indirect
google.golang.org/grpc v1.48.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
modernc.org/libc v1.21.1 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.4.0 // indirect
modernc.org/sqlite v1.19.2 // indirect
nhooyr.io/websocket v1.8.7 // indirect
google.golang.org/api v0.230.0 // indirect
google.golang.org/genproto v0.0.0-20250425173222-7b384671a197 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250425173222-7b384671a197 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect
google.golang.org/grpc v1.72.1 // indirect
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 // indirect
modernc.org/libc v1.50.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.29.8 // indirect
)
+1516 -415
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -42,7 +42,7 @@ func (p *OIDCProvider) GetLoginURL(redirectURI, state string) string {
ClientSecret: p.clientSecret,
RedirectURL: redirectURI,
Endpoint: p.provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
Scopes: p.scopes,
}
return oauth2Config.AuthCodeURL(state, oauth2.ApprovalForce)
@@ -54,7 +54,7 @@ func (p *OIDCProvider) Exchange(redirectURI, code string) (*User, error) {
ClientSecret: p.clientSecret,
RedirectURL: redirectURI,
Endpoint: p.provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
Scopes: p.scopes,
}
oauth2Token, err := oauth2Config.Exchange(context.Background(), code)
-163
View File
@@ -1,163 +0,0 @@
package bind
import (
"encoding/binary"
"encoding/json"
"fmt"
"github.com/jsiebens/ionscale/internal/util"
"github.com/klauspost/compress/zstd"
"github.com/labstack/echo/v4"
"io/ioutil"
"tailscale.com/types/key"
)
type Factory func(c echo.Context) (Binder, error)
type Binder interface {
BindRequest(c echo.Context, v interface{}) error
WriteResponse(c echo.Context, code int, v interface{}) error
Marshal(compress string, v interface{}) ([]byte, error)
Peer() key.MachinePublic
}
func DefaultBinder(machineKey key.MachinePublic) Factory {
return func(c echo.Context) (Binder, error) {
return &defaultBinder{machineKey: machineKey}, nil
}
}
func BoxBinder(controlKey key.MachinePrivate) Factory {
return func(c echo.Context) (Binder, error) {
idParam := c.Param("id")
id, err := util.ParseMachinePublicKey(idParam)
if err != nil {
return nil, err
}
return &boxBinder{
controlKey: controlKey,
machineKey: *id,
}, nil
}
}
type defaultBinder struct {
machineKey key.MachinePublic
}
func (d *defaultBinder) BindRequest(c echo.Context, v interface{}) error {
body, err := ioutil.ReadAll(c.Request().Body)
if err != nil {
return err
}
return json.Unmarshal(body, v)
}
func (d *defaultBinder) WriteResponse(c echo.Context, code int, v interface{}) error {
marshalled, err := json.Marshal(v)
if err != nil {
return err
}
c.Response().WriteHeader(code)
_, err = c.Response().Write(marshalled)
return err
}
func (d *defaultBinder) Marshal(compress string, v interface{}) ([]byte, error) {
var payload []byte
marshalled, err := json.Marshal(v)
if err != nil {
return nil, err
}
if compress == "zstd" {
encoder, err := zstd.NewWriter(nil)
if err != nil {
return nil, err
}
payload = encoder.EncodeAll(marshalled, nil)
} else {
payload = marshalled
}
data := make([]byte, 4)
binary.LittleEndian.PutUint32(data, uint32(len(payload)))
data = append(data, payload...)
return data, nil
}
func (d *defaultBinder) Peer() key.MachinePublic {
return d.machineKey
}
type boxBinder struct {
controlKey key.MachinePrivate
machineKey key.MachinePublic
}
func (b *boxBinder) BindRequest(c echo.Context, v interface{}) error {
body, err := ioutil.ReadAll(c.Request().Body)
if err != nil {
return err
}
decrypted, ok := b.controlKey.OpenFrom(b.machineKey, body)
if !ok {
return fmt.Errorf("unable to decrypt payload")
}
return json.Unmarshal(decrypted, v)
}
func (b *boxBinder) WriteResponse(c echo.Context, code int, v interface{}) error {
marshalled, err := json.Marshal(v)
if err != nil {
return err
}
encrypted := b.controlKey.SealTo(b.machineKey, marshalled)
c.Response().WriteHeader(code)
_, err = c.Response().Write(encrypted)
return err
}
func (b *boxBinder) Marshal(compress string, v interface{}) ([]byte, error) {
var payload []byte
marshalled, err := json.Marshal(v)
if err != nil {
return nil, err
}
if compress == "zstd" {
encoder, err := zstd.NewWriter(nil)
if err != nil {
return nil, err
}
encoded := encoder.EncodeAll(marshalled, nil)
payload = b.controlKey.SealTo(b.machineKey, encoded)
} else {
payload = b.controlKey.SealTo(b.machineKey, marshalled)
}
data := make([]byte, 4)
binary.LittleEndian.PutUint32(data, uint32(len(payload)))
data = append(data, payload...)
return data, nil
}
func (b *boxBinder) Peer() key.MachinePublic {
return b.machineKey
}
+23 -95
View File
@@ -2,55 +2,29 @@ package cmd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/go-edit/editor"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"io/ioutil"
"github.com/spf13/cobra"
"github.com/tailscale/hujson"
"os"
)
func getACLConfigCommand() *coral.Command {
command := &coral.Command{
func getACLConfigCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "get-acl-policy",
Short: "Get the ACL policy",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(cmd *coral.Command, args []string) error {
client, err := target.createGRPCClient()
command.RunE = func(cmd *cobra.Command, args []string) error {
resp, err := tc.Client().GetACLPolicy(cmd.Context(), connect.NewRequest(&api.GetACLPolicyRequest{TailnetId: tc.TailnetID()}))
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
resp, err := client.GetACLPolicy(context.Background(), connect.NewRequest(&api.GetACLPolicyRequest{TailnetId: tailnet.Id}))
if err != nil {
return err
}
marshal, err := json.MarshalIndent(resp.Msg.Policy, "", " ")
if err != nil {
return err
}
fmt.Println(string(marshal))
fmt.Println(resp.Msg.Policy)
return nil
}
@@ -58,58 +32,34 @@ func getACLConfigCommand() *coral.Command {
return command
}
func editACLConfigCommand() *coral.Command {
command := &coral.Command{
func editACLConfigCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "edit-acl-policy",
Short: "Edit the ACL policy",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(cmd *coral.Command, args []string) error {
command.RunE = func(cmd *cobra.Command, args []string) error {
edit := editor.NewDefaultEditor([]string{"IONSCALE_EDITOR", "EDITOR"})
client, err := target.createGRPCClient()
resp, err := tc.Client().GetACLPolicy(cmd.Context(), connect.NewRequest(&api.GetACLPolicyRequest{TailnetId: tc.TailnetID()}))
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
resp, err := client.GetACLPolicy(context.Background(), connect.NewRequest(&api.GetACLPolicyRequest{TailnetId: tailnet.Id}))
if err != nil {
return err
}
previous, err := json.MarshalIndent(resp.Msg.Policy, "", " ")
if err != nil {
return err
}
next, s, err := edit.LaunchTempFile("ionscale", ".json", bytes.NewReader(previous))
next, s, err := edit.LaunchTempFile("ionscale", ".json", bytes.NewReader([]byte(resp.Msg.Policy)))
if err != nil {
return err
}
defer os.Remove(s)
var policy = &api.ACLPolicy{}
if err := json.Unmarshal(next, policy); err != nil {
next, err = hujson.Standardize(next)
if err != nil {
return err
}
_, err = client.SetACLPolicy(context.Background(), connect.NewRequest(&api.SetACLPolicyRequest{TailnetId: tailnet.Id, Policy: policy}))
_, err = tc.Client().SetACLPolicy(cmd.Context(), connect.NewRequest(&api.SetACLPolicyRequest{TailnetId: tc.TailnetID(), Policy: string(next)}))
if err != nil {
return err
}
@@ -122,46 +72,24 @@ func editACLConfigCommand() *coral.Command {
return command
}
func setACLConfigCommand() *coral.Command {
command := &coral.Command{
func setACLConfigCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "set-acl-policy",
Short: "Set ACL policy",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var file string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.Flags().StringVar(&file, "file", "", "Path to json file with the acl configuration")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(cmd *coral.Command, args []string) error {
rawJson, err := ioutil.ReadFile(file)
command.RunE = func(cmd *cobra.Command, args []string) error {
content, err := os.ReadFile(file)
if err != nil {
return err
}
var policy = &api.ACLPolicy{}
if err := json.Unmarshal(rawJson, policy); err != nil {
return err
}
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
_, err = client.SetACLPolicy(context.Background(), connect.NewRequest(&api.SetACLPolicyRequest{TailnetId: tailnet.Id, Policy: policy}))
_, err = tc.Client().SetACLPolicy(cmd.Context(), connect.NewRequest(&api.SetACLPolicyRequest{TailnetId: tc.TailnetID(), Policy: string(content)}))
if err != nil {
return err
}
+15 -20
View File
@@ -1,16 +1,15 @@
package cmd
import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/ionscale/pkg/client/ionscale"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"github.com/spf13/cobra"
)
func authCommand() *coral.Command {
command := &coral.Command{
func authCommand() *cobra.Command {
command := &cobra.Command{
Use: "auth",
}
@@ -19,25 +18,15 @@ func authCommand() *coral.Command {
return command
}
func authLoginCommand() *coral.Command {
command := &coral.Command{
func authLoginCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "login",
SilenceUsage: true,
}
var target = Target{}
target.prepareCommand(command)
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
})
command.RunE = func(cmd *cobra.Command, args []string) error {
req := &api.AuthenticateRequest{}
stream, err := client.Authenticate(context.Background(), connect.NewRequest(req))
stream, err := tc.Client().Authenticate(cmd.Context(), connect.NewRequest(req))
if err != nil {
return err
}
@@ -48,7 +37,13 @@ func authLoginCommand() *coral.Command {
if len(resp.Token) != 0 {
fmt.Println()
fmt.Println("Success.")
if err := ionscale.SessionToFile(resp.Token, resp.TailnetId); err != nil {
tailnetId := uint64(0)
if resp.TailnetId != nil {
tailnetId = *resp.TailnetId
}
if err := ionscale.StoreAuthToken(tc.Addr(), resp.Token, tailnetId); err != nil {
fmt.Println()
fmt.Println("Your api token:")
fmt.Println()
+21 -64
View File
@@ -1,20 +1,19 @@
package cmd
import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"github.com/rodaine/table"
"github.com/spf13/cobra"
str2dur "github.com/xhit/go-str2duration/v2"
"google.golang.org/protobuf/types/known/durationpb"
"strings"
"time"
)
func authkeysCommand() *coral.Command {
command := &coral.Command{
func authkeysCommand() *cobra.Command {
command := &cobra.Command{
Use: "auth-keys",
Aliases: []string{"auth-key"},
Short: "Manage ionscale auth keys",
@@ -27,41 +26,24 @@ func authkeysCommand() *coral.Command {
return command
}
func createAuthkeysCommand() *coral.Command {
command := &coral.Command{
func createAuthkeysCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "create",
Short: "Creates a new auth key in the specified tailnet",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var ephemeral bool
var preAuthorized bool
var tags []string
var expiry string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.Flags().BoolVar(&ephemeral, "ephemeral", false, "When enabled, machines authenticated by this key will be automatically removed after going offline.")
command.Flags().StringSliceVar(&tags, "tag", []string{}, "Machines authenticated by this key will be automatically tagged with these tags")
command.Flags().StringVar(&expiry, "expiry", "180d", "Human-readable expiration of the key")
command.Flags().BoolVar(&preAuthorized, "pre-authorized", false, "Generate an auth key which is pre-authorized.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
var expiryDur *durationpb.Duration
if expiry != "" && expiry != "none" {
@@ -73,13 +55,13 @@ func createAuthkeysCommand() *coral.Command {
}
req := &api.CreateAuthKeyRequest{
TailnetId: tailnet.Id,
TailnetId: tc.TailnetID(),
Ephemeral: ephemeral,
PreAuthorized: preAuthorized,
Tags: tags,
Expiry: expiryDur,
}
resp, err := client.CreateAuthKey(context.Background(), connect.NewRequest(req))
resp, err := tc.Client().CreateAuthKey(cmd.Context(), connect.NewRequest(req))
if err != nil {
return err
@@ -98,26 +80,20 @@ func createAuthkeysCommand() *coral.Command {
return command
}
func deleteAuthKeyCommand() *coral.Command {
command := &coral.Command{
func deleteAuthKeyCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "delete",
Short: "Delete a specified auth key",
SilenceUsage: true,
}
})
var authKeyId uint64
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&authKeyId, "id", 0, "Auth Key ID")
command.RunE = func(command *coral.Command, args []string) error {
grpcClient, err := target.createGRPCClient()
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.DeleteAuthKeyRequest{AuthKeyId: authKeyId}
if _, err := grpcClient.DeleteAuthKey(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().DeleteAuthKey(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -129,35 +105,16 @@ func deleteAuthKeyCommand() *coral.Command {
return command
}
func listAuthkeysCommand() *coral.Command {
command := &coral.Command{
func listAuthkeysCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "list",
Short: "List all auth keys for a given tailnet",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
req := &api.ListAuthKeysRequest{TailnetId: tailnet.Id}
resp, err := client.ListAuthKeys(context.Background(), connect.NewRequest(req))
command.RunE = func(cmd *cobra.Command, args []string) error {
req := &api.ListAuthKeysRequest{TailnetId: tc.TailnetID()}
resp, err := tc.Client().ListAuthKeys(cmd.Context(), connect.NewRequest(req))
if err != nil {
return err
+7 -9
View File
@@ -5,13 +5,13 @@ import (
"fmt"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/key"
"github.com/muesli/coral"
"github.com/spf13/cobra"
"gopkg.in/yaml.v2"
"path/filepath"
)
func configureCommand() *coral.Command {
command := &coral.Command{
func configureCommand() *cobra.Command {
command := &cobra.Command{
Use: "configure",
Short: "Generate a simple config file to get started.",
SilenceUsage: true,
@@ -33,7 +33,7 @@ func configureCommand() *coral.Command {
command.MarkFlagRequired("domain")
command.PreRunE = func(cmd *coral.Command, args []string) error {
command.PreRunE = func(cmd *cobra.Command, args []string) error {
if domain == "" {
return errors.New("required flag 'domain' is missing")
}
@@ -49,13 +49,12 @@ func configureCommand() *coral.Command {
return nil
}
command.RunE = func(command *coral.Command, args []string) error {
command.RunE = func(command *cobra.Command, args []string) error {
c := &config.Config{}
c.HttpListenAddr = "0.0.0.0:80"
c.HttpsListenAddr = "0.0.0.0:443"
c.ListenAddr = "0.0.0.0:443"
c.MetricsListenAddr = "127.0.0.1:9090"
c.ServerUrl = fmt.Sprintf("https://%s", domain)
c.PublicAddr = fmt.Sprintf("%s:443", domain)
c.Keys = config.Keys{
ControlKey: key.NewServerKey().String(),
@@ -67,7 +66,6 @@ func configureCommand() *coral.Command {
if acme {
c.Tls.AcmeEnabled = true
c.Tls.AcmeEmail = email
c.Tls.AcmePath = filepath.Join(dataDir, "acme")
} else {
c.Tls.CertFile = certFile
c.Tls.KeyFile = keyFile
+9 -89
View File
@@ -1,50 +1,39 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"github.com/bufbuild/connect-go"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"github.com/spf13/cobra"
"gopkg.in/yaml.v2"
"os"
"tailscale.com/tailcfg"
)
func systemCommand() *coral.Command {
command := &coral.Command{
func systemCommand() *cobra.Command {
command := &cobra.Command{
Use: "system",
Short: "Manage global system configurations",
}
command.AddCommand(getDefaultDERPMap())
command.AddCommand(setDefaultDERPMap())
command.AddCommand(resetDefaultDERPMap())
return command
}
func getDefaultDERPMap() *coral.Command {
command := &coral.Command{
func getDefaultDERPMap() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "get-derp-map",
Short: "Get the DERP Map configuration",
Short: "Get the default DERP Map configuration",
SilenceUsage: true,
}
})
var asJson bool
var target = Target{}
target.prepareCommand(command)
command.Flags().BoolVar(&asJson, "json", false, "When enabled, render output as json otherwise yaml")
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
resp, err := client.GetDefaultDERPMap(context.Background(), connect.NewRequest(&api.GetDefaultDERPMapRequest{}))
command.RunE = func(cmd *cobra.Command, args []string) error {
resp, err := tc.Client().GetDefaultDERPMap(cmd.Context(), connect.NewRequest(&api.GetDefaultDERPMapRequest{}))
if err != nil {
return err
@@ -79,72 +68,3 @@ func getDefaultDERPMap() *coral.Command {
return command
}
func setDefaultDERPMap() *coral.Command {
command := &coral.Command{
Use: "set-derp-map",
Short: "Set the DERP Map configuration",
SilenceUsage: true,
}
var file string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&file, "file", "", "Path to json file with the DERP Map configuration")
command.RunE = func(command *coral.Command, args []string) error {
grpcClient, err := target.createGRPCClient()
if err != nil {
return err
}
rawJson, err := os.ReadFile(file)
if err != nil {
return err
}
resp, err := grpcClient.SetDefaultDERPMap(context.Background(), connect.NewRequest(&api.SetDefaultDERPMapRequest{Value: rawJson}))
if err != nil {
return err
}
var derpMap tailcfg.DERPMap
if err := json.Unmarshal(resp.Msg.Value, &derpMap); err != nil {
return err
}
fmt.Println("DERP Map updated successfully")
return nil
}
return command
}
func resetDefaultDERPMap() *coral.Command {
command := &coral.Command{
Use: "reset-derp-map",
Short: "Reset the DERP Map to the default configuration",
SilenceUsage: true,
}
var target = Target{}
target.prepareCommand(command)
command.RunE = func(command *coral.Command, args []string) error {
grpcClient, err := target.createGRPCClient()
if err != nil {
return err
}
if _, err := grpcClient.ResetDefaultDERPMap(context.Background(), connect.NewRequest(&api.ResetDefaultDERPMapRequest{})); err != nil {
return err
}
fmt.Println("DERP Map updated successfully")
return nil
}
return command
}
+64 -89
View File
@@ -1,80 +1,32 @@
package cmd
import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"github.com/spf13/cobra"
"os"
"strings"
"text/tabwriter"
)
func getDNSConfigCommand() *coral.Command {
command := &coral.Command{
func getDNSConfigCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "get-dns",
Short: "Get DNS configuration",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
req := api.GetDNSConfigRequest{TailnetId: tailnet.Id}
resp, err := client.GetDNSConfig(context.Background(), connect.NewRequest(&req))
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.GetDNSConfigRequest{TailnetId: tc.TailnetID()}
resp, err := tc.Client().GetDNSConfig(cmd.Context(), connect.NewRequest(&req))
if err != nil {
return err
}
config := resp.Msg.Config
w := new(tabwriter.Writer)
w.Init(os.Stdout, 8, 8, 1, '\t', 0)
defer w.Flush()
fmt.Fprintf(w, "%s\t\t%v\n", "MagicDNS", config.MagicDns)
fmt.Fprintf(w, "%s\t\t%v\n", "HTTPS Certs", config.HttpsCerts)
fmt.Fprintf(w, "%s\t\t%v\n", "Override Local DNS", config.OverrideLocalDns)
if config.MagicDns {
fmt.Fprintf(w, "MagicDNS\t%s\t%s\n", config.MagicDnsSuffix, "100.100.100.100")
}
for k, r := range config.Routes {
for i, t := range r.Routes {
if i == 0 {
fmt.Fprintf(w, "SplitDNS\t%s\t%s\n", k, t)
} else {
fmt.Fprintf(w, "%s\t%s\n", "", t)
}
}
}
for i, t := range config.Nameservers {
if i == 0 {
fmt.Fprintf(w, "%s\t%s\t%s\n", "Global", "", t)
} else {
fmt.Fprintf(w, "%s\t%s\t%s\n", "", "", t)
}
}
printDnsConfig(config)
return nil
}
@@ -82,40 +34,26 @@ func getDNSConfigCommand() *coral.Command {
return command
}
func setDNSConfigCommand() *coral.Command {
command := &coral.Command{
func setDNSConfigCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "set-dns",
Short: "Set DNS config",
SilenceUsage: true,
}
})
var nameservers []string
var magicDNS bool
var httpsCerts bool
var overrideLocalDNS bool
var tailnetID uint64
var tailnetName string
var target = Target{}
var searchDomains []string
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.Flags().StringSliceVarP(&nameservers, "nameserver", "", []string{}, "Machines on your network will use these nameservers to resolve DNS queries.")
command.Flags().BoolVarP(&magicDNS, "magic-dns", "", false, "Enable MagicDNS for the specified Tailnet")
command.Flags().BoolVarP(&magicDNS, "https-certs", "", false, "Enable HTTPS Certificates for the specified Tailnet")
command.Flags().BoolVarP(&httpsCerts, "https-certs", "", false, "Enable HTTPS Certificates for the specified Tailnet")
command.Flags().BoolVarP(&overrideLocalDNS, "override-local-dns", "", false, "When enabled, connected clients ignore local DNS settings and always use the nameservers specified for this Tailnet")
command.Flags().StringSliceVarP(&searchDomains, "search-domain", "", []string{}, "Custom DNS search domains.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
var globalNameservers []string
var routes = make(map[string]*api.Routes)
@@ -134,15 +72,17 @@ func setDNSConfigCommand() *coral.Command {
}
req := api.SetDNSConfigRequest{
TailnetId: tailnet.Id,
TailnetId: tc.TailnetID(),
Config: &api.DNSConfig{
MagicDns: magicDNS,
OverrideLocalDns: overrideLocalDNS,
Nameservers: globalNameservers,
Routes: routes,
HttpsCerts: httpsCerts,
SearchDomains: searchDomains,
},
}
resp, err := client.SetDNSConfig(context.Background(), connect.NewRequest(&req))
resp, err := tc.Client().SetDNSConfig(cmd.Context(), connect.NewRequest(&req))
if err != nil {
return err
@@ -150,20 +90,55 @@ func setDNSConfigCommand() *coral.Command {
config := resp.Msg.Config
var allNameservers = config.Nameservers
for i, j := range config.Routes {
for _, n := range j.Routes {
allNameservers = append(allNameservers, fmt.Sprintf("%s:%s", i, n))
}
if resp.Msg.Message != "" {
fmt.Println(resp.Msg.Message)
fmt.Println()
}
fmt.Printf("%-*v%v\n", 25, "Magic DNS Enabled:", config.MagicDns)
fmt.Printf("%-*v%v\n", 25, "Override Local DNS:", config.OverrideLocalDns)
fmt.Printf("%-*v%v\n", 25, "Nameservers:", strings.Join(allNameservers, ","))
printDnsConfig(config)
return nil
}
return command
}
func printDnsConfig(config *api.DNSConfig) {
w := new(tabwriter.Writer)
w.Init(os.Stdout, 8, 8, 1, '\t', 0)
defer w.Flush()
fmt.Fprintf(w, "%s\t\t%v\n", "MagicDNS", config.MagicDns)
fmt.Fprintf(w, "%s\t\t%v\n", "HTTPS Certs", config.HttpsCerts)
fmt.Fprintf(w, "%s\t\t%v\n", "Override Local DNS", config.OverrideLocalDns)
if config.MagicDns {
fmt.Fprintf(w, "MagicDNS\t%s\t%s\n", config.MagicDnsSuffix, "100.100.100.100")
}
for k, r := range config.Routes {
for i, t := range r.Routes {
if i == 0 {
fmt.Fprintf(w, "SplitDNS\t%s\t%s\n", k, t)
} else {
fmt.Fprintf(w, "%s\t%s\n", "", t)
}
}
}
for i, t := range config.Nameservers {
if i == 0 {
fmt.Fprintf(w, "%s\t%s\t%s\n", "Global", "", t)
} else {
fmt.Fprintf(w, "%s\t%s\t%s\n", "", "", t)
}
}
for i, t := range config.SearchDomains {
if i == 0 {
fmt.Fprintf(w, "%s\t%s\t%s\n", "Search Domains", t, "")
} else {
fmt.Fprintf(w, "%s\t%s\t%s\n", "", t, "")
}
}
}
-52
View File
@@ -1,52 +0,0 @@
package cmd
import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/ionscale/pkg/client/ionscale"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
apiconnect "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1/ionscalev1connect"
"github.com/muesli/coral"
)
func checkRequiredTailnetAndTailnetIdFlags(cmd *coral.Command, args []string) error {
savedTailnetID, err := ionscale.TailnetFromFile()
if err != nil {
return err
}
if savedTailnetID == 0 && !cmd.Flags().Changed("tailnet") && !cmd.Flags().Changed("tailnet-id") {
return fmt.Errorf("flag --tailnet or --tailnet-id is required")
}
if cmd.Flags().Changed("tailnet") && cmd.Flags().Changed("tailnet-id") {
return fmt.Errorf("flags --tailnet and --tailnet-id are mutually exclusive")
}
return nil
}
func findTailnet(client apiconnect.IonscaleServiceClient, tailnet string, tailnetID uint64) (*api.Tailnet, error) {
savedTailnetID, err := ionscale.TailnetFromFile()
if err != nil {
return nil, err
}
if savedTailnetID == 0 && tailnetID == 0 && tailnet == "" {
return nil, fmt.Errorf("requested tailnet not found or you are not authorized for this tailnet")
}
tailnets, err := client.ListTailnets(context.Background(), connect.NewRequest(&api.ListTailnetsRequest{}))
if err != nil {
return nil, err
}
for _, t := range tailnets.Msg.Tailnet {
if t.Id == savedTailnetID || t.Id == tailnetID || t.Name == tailnet {
return t, nil
}
}
return nil, fmt.Errorf("requested tailnet not found or you are not authorized for this tailnet")
}
+20 -98
View File
@@ -2,55 +2,28 @@ package cmd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/go-edit/editor"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"io/ioutil"
"github.com/spf13/cobra"
"os"
)
func getIAMPolicyCommand() *coral.Command {
command := &coral.Command{
func getIAMPolicyCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "get-iam-policy",
Short: "Get the IAM policy",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(cmd *coral.Command, args []string) error {
client, err := target.createGRPCClient()
command.RunE = func(cmd *cobra.Command, args []string) error {
resp, err := tc.Client().GetIAMPolicy(cmd.Context(), connect.NewRequest(&api.GetIAMPolicyRequest{TailnetId: tc.TailnetID()}))
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
resp, err := client.GetIAMPolicy(context.Background(), connect.NewRequest(&api.GetIAMPolicyRequest{TailnetId: tailnet.Id}))
if err != nil {
return err
}
marshal, err := json.MarshalIndent(resp.Msg.Policy, "", " ")
if err != nil {
return err
}
fmt.Println(string(marshal))
fmt.Println(resp.Msg.Policy)
return nil
}
@@ -58,58 +31,29 @@ func getIAMPolicyCommand() *coral.Command {
return command
}
func editIAMPolicyCommand() *coral.Command {
command := &coral.Command{
func editIAMPolicyCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "edit-iam-policy",
Short: "Edit the IAM policy",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(cmd *coral.Command, args []string) error {
command.RunE = func(cmd *cobra.Command, args []string) error {
edit := editor.NewDefaultEditor([]string{"IONSCALE_EDITOR", "EDITOR"})
client, err := target.createGRPCClient()
resp, err := tc.Client().GetIAMPolicy(cmd.Context(), connect.NewRequest(&api.GetIAMPolicyRequest{TailnetId: tc.TailnetID()}))
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
resp, err := client.GetIAMPolicy(context.Background(), connect.NewRequest(&api.GetIAMPolicyRequest{TailnetId: tailnet.Id}))
if err != nil {
return err
}
previous, err := json.MarshalIndent(resp.Msg.Policy, "", " ")
if err != nil {
return err
}
next, s, err := edit.LaunchTempFile("ionscale", ".json", bytes.NewReader(previous))
next, s, err := edit.LaunchTempFile("ionscale", ".json", bytes.NewReader([]byte(resp.Msg.Policy)))
if err != nil {
return err
}
defer os.Remove(s)
var policy = &api.IAMPolicy{}
if err := json.Unmarshal(next, policy); err != nil {
return err
}
_, err = client.SetIAMPolicy(context.Background(), connect.NewRequest(&api.SetIAMPolicyRequest{TailnetId: tailnet.Id, Policy: policy}))
_, err = tc.Client().SetIAMPolicy(cmd.Context(), connect.NewRequest(&api.SetIAMPolicyRequest{TailnetId: tc.TailnetID(), Policy: string(next)}))
if err != nil {
return err
}
@@ -122,46 +66,24 @@ func editIAMPolicyCommand() *coral.Command {
return command
}
func setIAMPolicyCommand() *coral.Command {
command := &coral.Command{
func setIAMPolicyCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "set-iam-policy",
Short: "Set IAM policy",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var file string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.Flags().StringVar(&file, "file", "", "Path to json file with the acl configuration")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(cmd *coral.Command, args []string) error {
rawJson, err := ioutil.ReadFile(file)
command.RunE = func(cmd *cobra.Command, args []string) error {
content, err := os.ReadFile(file)
if err != nil {
return err
}
var policy = &api.IAMPolicy{}
if err := json.Unmarshal(rawJson, policy); err != nil {
return err
}
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
_, err = client.SetIAMPolicy(context.Background(), connect.NewRequest(&api.SetIAMPolicyRequest{TailnetId: tailnet.Id, Policy: policy}))
_, err = tc.Client().SetIAMPolicy(cmd.Context(), connect.NewRequest(&api.SetIAMPolicyRequest{TailnetId: tc.TailnetID(), Policy: string(content)}))
if err != nil {
return err
}
+4 -4
View File
@@ -3,11 +3,11 @@ package cmd
import (
"fmt"
"github.com/jsiebens/ionscale/internal/key"
"github.com/muesli/coral"
"github.com/spf13/cobra"
)
func keyCommand() *coral.Command {
command := &coral.Command{
func keyCommand() *cobra.Command {
command := &cobra.Command{
Use: "genkey",
SilenceUsage: true,
}
@@ -16,7 +16,7 @@ func keyCommand() *coral.Command {
command.Flags().BoolVarP(&disableNewLine, "no-newline", "n", false, "do not output a trailing newline")
command.RunE = func(command *coral.Command, args []string) error {
command.RunE = func(command *cobra.Command, args []string) error {
serverKey := key.NewServerKey()
if disableNewLine {
fmt.Print(serverKey.String())
+106 -155
View File
@@ -1,23 +1,22 @@
package cmd
import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"github.com/nleeper/goment"
"github.com/rodaine/table"
"github.com/spf13/cobra"
"inet.af/netaddr"
"os"
"strings"
"text/tabwriter"
)
func machineCommands() *coral.Command {
command := &coral.Command{
func machineCommands() *cobra.Command {
command := &cobra.Command{
Use: "machines",
Aliases: []string{"machine"},
Aliases: []string{"machine", "devices", "device"},
Short: "Manage ionscale machines",
SilenceUsage: true,
}
@@ -34,32 +33,26 @@ func machineCommands() *coral.Command {
command.AddCommand(disableExitNodeCommand())
command.AddCommand(disableMachineKeyExpiryCommand())
command.AddCommand(authorizeMachineCommand())
command.AddCommand(setMachineNameCommand())
return command
}
func getMachineCommand() *coral.Command {
command := &coral.Command{
func getMachineCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "get",
Short: "Retrieve detailed information for a machine",
SilenceUsage: true,
}
})
var machineID uint64
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&machineID, "machine-id", 0, "Machine ID.")
_ = command.MarkFlagRequired("machine-id")
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.GetMachineRequest{MachineId: machineID}
resp, err := client.GetMachine(context.Background(), connect.NewRequest(&req))
resp, err := tc.Client().GetMachine(cmd.Context(), connect.NewRequest(&req))
if err != nil {
return err
}
@@ -150,28 +143,21 @@ func getMachineCommand() *coral.Command {
return command
}
func deleteMachineCommand() *coral.Command {
command := &coral.Command{
func deleteMachineCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "delete",
Short: "Deletes a machine",
SilenceUsage: true,
}
})
var machineID uint64
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&machineID, "machine-id", 0, "Machine ID.")
_ = command.MarkFlagRequired("machine-id")
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.DeleteMachineRequest{MachineId: machineID}
if _, err := client.DeleteMachine(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().DeleteMachine(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -183,28 +169,55 @@ func deleteMachineCommand() *coral.Command {
return command
}
func expireMachineCommand() *coral.Command {
command := &coral.Command{
func setMachineNameCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "set-name",
Short: "Set the name of a given machine",
SilenceUsage: true,
})
var machineID uint64
var useOSHostname bool
var name string
command.Flags().Uint64Var(&machineID, "machine-id", 0, "Machine ID")
command.Flags().StringVar(&name, "name", "", "New name for the machine")
command.Flags().BoolVar(&useOSHostname, "use-os-hostname", false, "Auto-generate from the machine OS hostname")
_ = command.MarkFlagRequired("machine-id")
command.RunE = func(cmd *cobra.Command, args []string) error {
if !useOSHostname && name == "" {
return fmt.Errorf("name is required when not using os hostname")
}
req := api.SetMachineNameRequest{MachineId: machineID, Name: name, UseOsHostname: useOSHostname}
if _, err := tc.Client().SetMachineName(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
fmt.Println("Machine name set.")
return nil
}
return command
}
func expireMachineCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "expire",
Short: "Expires a machine",
SilenceUsage: true,
}
})
var machineID uint64
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&machineID, "machine-id", 0, "Machine ID.")
_ = command.MarkFlagRequired("machine-id")
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.ExpireMachineRequest{MachineId: machineID}
if _, err := client.ExpireMachine(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().ExpireMachine(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -216,28 +229,21 @@ func expireMachineCommand() *coral.Command {
return command
}
func authorizeMachineCommand() *coral.Command {
command := &coral.Command{
func authorizeMachineCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "authorize",
Short: "Authorizes a machine",
SilenceUsage: true,
}
})
var machineID uint64
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&machineID, "machine-id", 0, "Machine ID.")
_ = command.MarkFlagRequired("machine-id")
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.AuthorizeMachineRequest{MachineId: machineID}
if _, err := client.AuthorizeMachine(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().AuthorizeMachine(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -249,41 +255,22 @@ func authorizeMachineCommand() *coral.Command {
return command
}
func listMachinesCommand() *coral.Command {
command := &coral.Command{
func listMachinesCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "list",
Short: "List machines",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
req := api.ListMachinesRequest{TailnetId: tailnet.Id}
resp, err := client.ListMachines(context.Background(), connect.NewRequest(&req))
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.ListMachinesRequest{TailnetId: tc.TailnetID()}
resp, err := tc.Client().ListMachines(cmd.Context(), connect.NewRequest(&req))
if err != nil {
return err
}
tbl := table.New("ID", "TAILNET", "NAME", "IPv4", "IPv6", "AUTHORIZED", "EPHEMERAL", "LAST_SEEN", "TAGS")
tbl := table.New("ID", "TAILNET", "NAME", "IPv4", "IPv6", "AUTHORIZED", "EPHEMERAL", "VERSION", "LAST_SEEN", "TAGS")
for _, m := range resp.Msg.Machines {
var lastSeen = "N/A"
if m.Connected {
@@ -294,7 +281,7 @@ func listMachinesCommand() *coral.Command {
lastSeen = mom.FromNow()
}
}
tbl.AddRow(m.Id, m.Tailnet.Name, m.Name, m.Ipv4, m.Ipv6, m.Authorized, m.Ephemeral, lastSeen, strings.Join(m.Tags, ","))
tbl.AddRow(m.Id, m.Tailnet.Name, m.Name, m.Ipv4, m.Ipv6, m.Authorized, m.Ephemeral, m.ClientVersion, lastSeen, strings.Join(m.Tags, ","))
}
tbl.Print()
@@ -304,28 +291,21 @@ func listMachinesCommand() *coral.Command {
return command
}
func getMachineRoutesCommand() *coral.Command {
command := &coral.Command{
func getMachineRoutesCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "get-routes",
Short: "Show routes advertised and enabled by a given machine",
SilenceUsage: true,
}
})
var machineID uint64
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&machineID, "machine-id", 0, "Machine ID.")
_ = command.MarkFlagRequired("machine-id")
command.RunE = func(command *coral.Command, args []string) error {
grpcClient, err := target.createGRPCClient()
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.GetMachineRoutesRequest{MachineId: machineID}
resp, err := grpcClient.GetMachineRoutes(context.Background(), connect.NewRequest(&req))
resp, err := tc.Client().GetMachineRoutes(cmd.Context(), connect.NewRequest(&req))
if err != nil {
return err
}
@@ -338,30 +318,24 @@ func getMachineRoutesCommand() *coral.Command {
return command
}
func enableMachineRoutesCommand() *coral.Command {
command := &coral.Command{
func enableMachineRoutesCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "enable-routes",
Short: "Enable routes for a given machine",
SilenceUsage: true,
}
})
var machineID uint64
var routes []string
var replace bool
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&machineID, "machine-id", 0, "Machine ID")
command.Flags().StringSliceVar(&routes, "routes", []string{}, "List of routes to enable")
command.Flags().BoolVar(&replace, "replace", false, "Replace current enabled routes with this new list")
_ = command.MarkFlagRequired("machine-id")
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
for _, r := range routes {
if _, err := netaddr.ParseIPPrefix(r); err != nil {
return err
@@ -369,7 +343,7 @@ func enableMachineRoutesCommand() *coral.Command {
}
req := api.EnableMachineRoutesRequest{MachineId: machineID, Routes: routes, Replace: replace}
resp, err := client.EnableMachineRoutes(context.Background(), connect.NewRequest(&req))
resp, err := tc.Client().EnableMachineRoutes(cmd.Context(), connect.NewRequest(&req))
if err != nil {
return err
}
@@ -382,28 +356,22 @@ func enableMachineRoutesCommand() *coral.Command {
return command
}
func disableMachineRoutesCommand() *coral.Command {
command := &coral.Command{
func disableMachineRoutesCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "disable-routes",
Short: "Disable routes for a given machine",
SilenceUsage: true,
}
})
var machineID uint64
var routes []string
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&machineID, "machine-id", 0, "Machine ID")
command.Flags().StringSliceVar(&routes, "routes", []string{}, "List of routes to enable")
_ = command.MarkFlagRequired("machine-id")
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
for _, r := range routes {
if _, err := netaddr.ParseIPPrefix(r); err != nil {
return err
@@ -411,7 +379,7 @@ func disableMachineRoutesCommand() *coral.Command {
}
req := api.DisableMachineRoutesRequest{MachineId: machineID, Routes: routes}
resp, err := client.DisableMachineRoutes(context.Background(), connect.NewRequest(&req))
resp, err := tc.Client().DisableMachineRoutes(cmd.Context(), connect.NewRequest(&req))
if err != nil {
return err
}
@@ -424,28 +392,21 @@ func disableMachineRoutesCommand() *coral.Command {
return command
}
func enableExitNodeCommand() *coral.Command {
command := &coral.Command{
func enableExitNodeCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "enable-exit-node",
Short: "Enable given machine as an exit node",
SilenceUsage: true,
}
})
var machineID uint64
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&machineID, "machine-id", 0, "Machine ID")
_ = command.MarkFlagRequired("machine-id")
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.EnableExitNodeRequest{MachineId: machineID}
resp, err := client.EnableExitNode(context.Background(), connect.NewRequest(&req))
resp, err := tc.Client().EnableExitNode(cmd.Context(), connect.NewRequest(&req))
if err != nil {
return err
}
@@ -458,28 +419,22 @@ func enableExitNodeCommand() *coral.Command {
return command
}
func disableExitNodeCommand() *coral.Command {
command := &coral.Command{
func disableExitNodeCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "disable-exit-node",
Short: "Disable given machine as an exit node",
SilenceUsage: true,
}
})
var machineID uint64
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&machineID, "machine-id", 0, "Machine ID")
_ = command.MarkFlagRequired("machine-id")
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.DisableExitNodeRequest{MachineId: machineID}
resp, err := client.DisableExitNode(context.Background(), connect.NewRequest(&req))
resp, err := tc.Client().DisableExitNode(cmd.Context(), connect.NewRequest(&req))
if err != nil {
return err
}
@@ -492,8 +447,8 @@ func disableExitNodeCommand() *coral.Command {
return command
}
func enableMachineKeyExpiryCommand() *coral.Command {
command := &coral.Command{
func enableMachineKeyExpiryCommand() *cobra.Command {
command := &cobra.Command{
Use: "enable-key-expiry",
Short: "Enable machine key expiry",
SilenceUsage: true,
@@ -502,8 +457,8 @@ func enableMachineKeyExpiryCommand() *coral.Command {
return configureSetMachineKeyExpiryCommand(command, false)
}
func disableMachineKeyExpiryCommand() *coral.Command {
command := &coral.Command{
func disableMachineKeyExpiryCommand() *cobra.Command {
command := &cobra.Command{
Use: "disable-key-expiry",
Short: "Disable machine key expiry",
SilenceUsage: true,
@@ -512,22 +467,18 @@ func disableMachineKeyExpiryCommand() *coral.Command {
return configureSetMachineKeyExpiryCommand(command, true)
}
func configureSetMachineKeyExpiryCommand(command *coral.Command, v bool) *coral.Command {
func configureSetMachineKeyExpiryCommand(cmdTmpl *cobra.Command, disable bool) *cobra.Command {
command, tc := prepareCommand(false, cmdTmpl)
var machineID uint64
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&machineID, "machine-id", 0, "Machine ID")
_ = command.MarkFlagRequired("machine-id")
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
req := api.SetMachineKeyExpiryRequest{MachineId: machineID, Disabled: v}
_, err = client.SetMachineKeyExpiry(context.Background(), connect.NewRequest(&req))
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.SetMachineKeyExpiryRequest{MachineId: machineID, Disabled: disable}
_, err := tc.Client().SetMachineKeyExpiry(cmd.Context(), connect.NewRequest(&req))
if err != nil {
return err
}
+28
View File
@@ -0,0 +1,28 @@
package cmd
import (
"github.com/jsiebens/ionscale/pkg/ssh"
"github.com/spf13/cobra"
)
func recorderCommand() *cobra.Command {
t := ssh.RecorderConfig{}
command := &cobra.Command{
Use: "recorder",
Short: "Start an SSH Recorder",
SilenceUsage: true,
}
command.Flags().StringVar(&t.LoginServer, "login-server", "", "Base URL of control server")
command.Flags().StringVar(&t.StateDir, "statedir", "", "Directory where the recorder should store its internal state")
command.Flags().StringVar(&t.Dir, "dst", "", "Directory where recordings will be saved.")
command.Flags().StringVar(&t.AuthKey, "auth-key", "", "")
command.Flags().StringVar(&t.Hostname, "hostname", "recorder", "")
command.RunE = func(command *cobra.Command, args []string) error {
return ssh.Start(command.Context(), t)
}
return command
}
+5 -4
View File
@@ -1,10 +1,10 @@
package cmd
import (
"github.com/muesli/coral"
"github.com/spf13/cobra"
)
func Command() *coral.Command {
func Command() *cobra.Command {
rootCmd := rootCommand()
rootCmd.AddCommand(configureCommand())
rootCmd.AddCommand(keyCommand())
@@ -16,6 +16,7 @@ func Command() *coral.Command {
rootCmd.AddCommand(machineCommands())
rootCmd.AddCommand(userCommands())
rootCmd.AddCommand(systemCommand())
rootCmd.AddCommand(recorderCommand())
return rootCmd
}
@@ -24,8 +25,8 @@ func Execute() error {
return Command().Execute()
}
func rootCommand() *coral.Command {
return &coral.Command{
func rootCommand() *cobra.Command {
return &cobra.Command{
Use: "ionscale",
}
}
+5 -5
View File
@@ -3,11 +3,11 @@ package cmd
import (
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/server"
"github.com/muesli/coral"
"github.com/spf13/cobra"
)
func serverCommand() *coral.Command {
command := &coral.Command{
func serverCommand() *cobra.Command {
command := &cobra.Command{
Use: "server",
Short: "Start an ionscale server",
SilenceUsage: true,
@@ -17,14 +17,14 @@ func serverCommand() *coral.Command {
command.Flags().StringVarP(&configFile, "config", "c", "", "Path to the configuration file.")
command.RunE = func(command *coral.Command, args []string) error {
command.RunE = func(command *cobra.Command, args []string) error {
c, err := config.LoadConfig(configFile)
if err != nil {
return err
}
return server.Start(c)
return server.Start(command.Context(), c)
}
return command
+100 -328
View File
@@ -1,22 +1,23 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"github.com/bufbuild/connect-go"
idomain "github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/pkg/client/ionscale"
"github.com/jsiebens/ionscale/pkg/defaults"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"github.com/rodaine/table"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"os"
"strings"
"tailscale.com/tailcfg"
)
func tailnetCommand() *coral.Command {
command := &coral.Command{
func tailnetCommand() *cobra.Command {
command := &cobra.Command{
Use: "tailnets",
Aliases: []string{"tailnet"},
Short: "Manage ionscale tailnets",
@@ -48,24 +49,15 @@ func tailnetCommand() *coral.Command {
return command
}
func listTailnetsCommand() *coral.Command {
command := &coral.Command{
func listTailnetsCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "list",
Short: "List available Tailnets",
SilenceUsage: true,
}
})
var target = Target{}
target.prepareCommand(command)
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
resp, err := client.ListTailnets(context.Background(), connect.NewRequest(&api.ListTailnetsRequest{}))
command.RunE = func(cmd *cobra.Command, args []string) error {
resp, err := tc.Client().ListTailnets(cmd.Context(), connect.NewRequest(&api.ListTailnetsRequest{}))
if err != nil {
return err
@@ -83,24 +75,22 @@ func listTailnetsCommand() *coral.Command {
return command
}
func createTailnetsCommand() *coral.Command {
command := &coral.Command{
func createTailnetsCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "create",
Short: "Create a new Tailnet",
SilenceUsage: true,
}
})
var name string
var domain string
var email string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVarP(&name, "name", "n", "", "")
command.Flags().StringVar(&domain, "domain", "", "")
command.Flags().StringVar(&email, "email", "", "")
command.PreRunE = func(cmd *coral.Command, args []string) error {
command.PreRunE = func(cmd *cobra.Command, args []string) error {
if name == "" {
return fmt.Errorf("flag --name is required")
}
@@ -110,35 +100,42 @@ func createTailnetsCommand() *coral.Command {
return nil
}
command.RunE = func(command *coral.Command, args []string) error {
command.RunE = func(cmd *cobra.Command, args []string) error {
var iamPolicy = api.IAMPolicy{}
dnsConfig := defaults.DefaultDNSConfig()
aclPolicy := defaults.DefaultACLPolicy().Marshal()
iamPolicy := "{}"
if len(domain) != 0 {
domainToLower := strings.ToLower(domain)
iamPolicy = api.IAMPolicy{
m, err := json.MarshalIndent(&ionscale.IAMPolicy{
Filters: []string{fmt.Sprintf("domain == %s", domainToLower)},
}, "", " ")
if err != nil {
return err
}
iamPolicy = string(m)
}
if len(email) != 0 {
emailToLower := strings.ToLower(email)
iamPolicy = api.IAMPolicy{
m, err := json.MarshalIndent(&ionscale.IAMPolicy{
Emails: []string{emailToLower},
Roles: map[string]string{
emailToLower: string(idomain.UserRoleAdmin),
},
}, "", " ")
if err != nil {
return err
}
iamPolicy = string(m)
}
client, err := target.createGRPCClient()
if err != nil {
return err
}
resp, err := client.CreateTailnet(context.Background(), connect.NewRequest(&api.CreateTailnetRequest{
resp, err := tc.Client().CreateTailnet(cmd.Context(), connect.NewRequest(&api.CreateTailnetRequest{
Name: name,
IamPolicy: &iamPolicy,
IamPolicy: iamPolicy,
AclPolicy: aclPolicy,
DnsConfig: dnsConfig,
}))
if err != nil {
@@ -155,38 +152,19 @@ func createTailnetsCommand() *coral.Command {
return command
}
func deleteTailnetCommand() *coral.Command {
command := &coral.Command{
func deleteTailnetCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "delete",
Short: "Delete a tailnet",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var force bool
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.Flags().BoolVar(&force, "force", false, "When enabled, force delete the specified Tailnet even when machines are still available.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
_, err = client.DeleteTailnet(context.Background(), connect.NewRequest(&api.DeleteTailnetRequest{TailnetId: tailnet.Id, Force: force}))
command.RunE = func(cmd *cobra.Command, args []string) error {
_, err := tc.Client().DeleteTailnet(cmd.Context(), connect.NewRequest(&api.DeleteTailnetRequest{TailnetId: tc.TailnetID(), Force: force}))
if err != nil {
return err
}
@@ -199,37 +177,19 @@ func deleteTailnetCommand() *coral.Command {
return command
}
func getDERPMap() *coral.Command {
command := &coral.Command{
func getDERPMap() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "get-derp-map",
Short: "Get the DERP Map configuration",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var asJson bool
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.Flags().BoolVar(&asJson, "json", false, "When enabled, render output as json otherwise yaml")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
resp, err := client.GetDERPMap(context.Background(), connect.NewRequest(&api.GetDERPMapRequest{TailnetId: tailnet.Id}))
command.RunE = func(cmd *cobra.Command, args []string) error {
resp, err := tc.Client().GetDERPMap(cmd.Context(), connect.NewRequest(&api.GetDERPMapRequest{TailnetId: tc.TailnetID()}))
if err != nil {
return err
@@ -265,41 +225,24 @@ func getDERPMap() *coral.Command {
return command
}
func setDERPMap() *coral.Command {
command := &coral.Command{
func setDERPMap() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "set-derp-map",
Short: "Set the DERP Map configuration",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var file string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.Flags().StringVar(&file, "file", "", "Path to json file with the DERP Map configuration")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
rawJson, err := os.ReadFile(file)
if err != nil {
return err
}
resp, err := client.SetDERPMap(context.Background(), connect.NewRequest(&api.SetDERPMapRequest{TailnetId: tailnet.Id, Value: rawJson}))
resp, err := tc.Client().SetDERPMap(cmd.Context(), connect.NewRequest(&api.SetDERPMapRequest{TailnetId: tc.TailnetID(), Value: rawJson}))
if err != nil {
return err
}
@@ -317,34 +260,15 @@ func setDERPMap() *coral.Command {
return command
}
func resetDERPMap() *coral.Command {
command := &coral.Command{
func resetDERPMap() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "reset-derp-map",
Short: "Reset the DERP Map to the default configuration",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
if _, err := client.ResetDERPMap(context.Background(), connect.NewRequest(&api.ResetDERPMapRequest{TailnetId: tailnet.Id})); err != nil {
command.RunE = func(cmd *cobra.Command, args []string) error {
if _, err := tc.Client().ResetDERPMap(cmd.Context(), connect.NewRequest(&api.ResetDERPMapRequest{TailnetId: tc.TailnetID()})); err != nil {
return err
}
@@ -356,39 +280,20 @@ func resetDERPMap() *coral.Command {
return command
}
func enableFileSharingCommand() *coral.Command {
command := &coral.Command{
func enableFileSharingCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "enable-file-sharing",
Aliases: []string{"enable-taildrop"},
Short: "Enable Taildrop, the file sharing feature",
SilenceUsage: true,
}
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
})
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.EnableFileSharingRequest{
TailnetId: tailnet.Id,
TailnetId: tc.TailnetID(),
}
if _, err := client.EnableFileSharing(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().EnableFileSharing(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -398,39 +303,20 @@ func enableFileSharingCommand() *coral.Command {
return command
}
func disableFileSharingCommand() *coral.Command {
command := &coral.Command{
func disableFileSharingCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "disable-file-sharing",
Aliases: []string{"disable-taildrop"},
Short: "Disable Taildrop, the file sharing feature",
SilenceUsage: true,
}
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
})
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.DisableFileSharingRequest{
TailnetId: tailnet.Id,
TailnetId: tc.TailnetID(),
}
if _, err := client.DisableFileSharing(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().DisableFileSharing(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -440,38 +326,19 @@ func disableFileSharingCommand() *coral.Command {
return command
}
func enableServiceCollectionCommand() *coral.Command {
command := &coral.Command{
func enableServiceCollectionCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "enable-service-collection",
Short: "Enable monitoring live services running on your networks machines.",
SilenceUsage: true,
}
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
})
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.EnableServiceCollectionRequest{
TailnetId: tailnet.Id,
TailnetId: tc.TailnetID(),
}
if _, err := client.EnableServiceCollection(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().EnableServiceCollection(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -481,38 +348,19 @@ func enableServiceCollectionCommand() *coral.Command {
return command
}
func disableServiceCollectionCommand() *coral.Command {
command := &coral.Command{
func disableServiceCollectionCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "disable-service-collection",
Short: "Disable monitoring live services running on your networks machines.",
SilenceUsage: true,
}
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
})
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.DisableServiceCollectionRequest{
TailnetId: tailnet.Id,
TailnetId: tc.TailnetID(),
}
if _, err := client.DisableServiceCollection(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().DisableServiceCollection(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -522,38 +370,19 @@ func disableServiceCollectionCommand() *coral.Command {
return command
}
func enableSSHCommand() *coral.Command {
command := &coral.Command{
func enableSSHCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "enable-ssh",
Short: "Enable ssh access using tailnet and ACLs.",
SilenceUsage: true,
}
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
})
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.EnableSSHRequest{
TailnetId: tailnet.Id,
TailnetId: tc.TailnetID(),
}
if _, err := client.EnableSSH(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().EnableSSH(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -563,38 +392,19 @@ func enableSSHCommand() *coral.Command {
return command
}
func disableSSHCommand() *coral.Command {
command := &coral.Command{
func disableSSHCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "disable-ssh",
Short: "Disable ssh access using tailnet and ACLs.",
SilenceUsage: true,
}
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
})
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.DisableSSHRequest{
TailnetId: tailnet.Id,
TailnetId: tc.TailnetID(),
}
if _, err := client.DisableSSH(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().DisableSSH(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -604,38 +414,19 @@ func disableSSHCommand() *coral.Command {
return command
}
func enableMachineAuthorizationCommand() *coral.Command {
command := &coral.Command{
func enableMachineAuthorizationCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "enable-machine-authorization",
Short: "Enable machine authorization.",
SilenceUsage: true,
}
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
})
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.EnableMachineAuthorizationRequest{
TailnetId: tailnet.Id,
TailnetId: tc.TailnetID(),
}
if _, err := client.EnableMachineAuthorization(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().EnableMachineAuthorization(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -645,38 +436,19 @@ func enableMachineAuthorizationCommand() *coral.Command {
return command
}
func disableMachineAuthorizationCommand() *coral.Command {
command := &coral.Command{
func disableMachineAuthorizationCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "disable-machine-authorization",
Short: "Disable machine authorization.",
SilenceUsage: true,
}
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
})
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.DisableMachineAuthorizationRequest{
TailnetId: tailnet.Id,
TailnetId: tc.TailnetID(),
}
if _, err := client.DisableMachineAuthorization(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().DisableMachineAuthorization(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
+92 -17
View File
@@ -1,60 +1,135 @@
package cmd
import (
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/pkg/client/ionscale"
ionscalev1 "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1/ionscalev1connect"
"github.com/muesli/coral"
"github.com/spf13/cobra"
)
const (
ionscaleSystemAdminKey = "IONSCALE_SYSTEM_ADMIN_KEY"
ionscaleKeysSystemAdminKey = "IONSCALE_KEYS_SYSTEM_ADMIN_KEY"
ionscaleAddr = "IONSCALE_ADDR"
ionscaleInsecureSkipVerify = "IONSCALE_SKIP_VERIFY"
)
type Target struct {
type TargetContext interface {
Client() api.IonscaleServiceClient
Addr() string
TailnetID() uint64
}
type target struct {
addr string
insecureSkipVerify bool
systemAdminKey string
tailnetID uint64
tailnetName string
client api.IonscaleServiceClient
tailnet *ionscalev1.Tailnet
}
func (t *Target) prepareCommand(cmd *coral.Command) {
func prepareCommand(enableTailnetSelector bool, cmd *cobra.Command) (*cobra.Command, TargetContext) {
t := &target{}
cmd.Flags().StringVar(&t.addr, "addr", "", "Addr of the ionscale server, as a complete URL")
cmd.Flags().BoolVar(&t.insecureSkipVerify, "tls-skip-verify", false, "Disable verification of TLS certificates")
cmd.Flags().StringVar(&t.systemAdminKey, "system-admin-key", "", "If specified, the given value will be used as the key to generate a Bearer token for the call. This can also be specified via the IONSCALE_ADMIN_KEY environment variable.")
}
func (t *Target) createGRPCClient() (api.IonscaleServiceClient, error) {
addr := t.getAddr()
skipVerify := t.getInsecureSkipVerify()
systemAdminKey := t.getSystemAdminKey()
auth, err := ionscale.LoadClientAuth(systemAdminKey)
if err != nil {
return nil, err
if enableTailnetSelector {
cmd.Flags().StringVar(&t.tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
cmd.Flags().Uint64Var(&t.tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
}
return ionscale.NewClient(auth, addr, skipVerify)
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
addr := t.getAddr()
skipVerify := t.getInsecureSkipVerify()
systemAdminKey := t.getSystemAdminKey()
auth, err := ionscale.LoadClientAuth(addr, systemAdminKey)
if err != nil {
return err
}
client, err := ionscale.NewClient(auth, addr, skipVerify)
if err != nil {
return err
}
t.client = client
if enableTailnetSelector {
savedTailnetID := auth.TailnetID()
if savedTailnetID == 0 && !cmd.Flags().Changed("tailnet") && !cmd.Flags().Changed("tailnet-id") {
return fmt.Errorf("flag --tailnet or --tailnet-id is required")
}
if cmd.Flags().Changed("tailnet") && cmd.Flags().Changed("tailnet-id") {
return fmt.Errorf("flags --tailnet and --tailnet-id are mutually exclusive")
}
tailnets, err := t.client.ListTailnets(cmd.Context(), connect.NewRequest(&ionscalev1.ListTailnetsRequest{}))
if err != nil {
return err
}
for _, tailnet := range tailnets.Msg.Tailnet {
if tailnet.Id == savedTailnetID || tailnet.Id == t.tailnetID || tailnet.Name == t.tailnetName {
t.tailnet = tailnet
break
}
}
if t.tailnet == nil {
return fmt.Errorf("requested tailnet not found or you are not authorized for this tailnet")
}
}
return nil
}
return cmd, t
}
func (t *Target) getAddr() string {
func (t *target) getAddr() string {
if len(t.addr) != 0 {
return t.addr
}
return config.GetString(ionscaleAddr, "https://localhost:8443")
}
func (t *Target) getInsecureSkipVerify() bool {
func (t *target) getInsecureSkipVerify() bool {
if t.insecureSkipVerify {
return true
}
return config.GetBool(ionscaleInsecureSkipVerify, false)
}
func (t *Target) getSystemAdminKey() string {
func (t *target) getSystemAdminKey() string {
if len(t.systemAdminKey) != 0 {
return t.systemAdminKey
}
return config.GetString(ionscaleSystemAdminKey, "")
return config.GetString(ionscaleSystemAdminKey, config.GetString(ionscaleKeysSystemAdminKey, ""))
}
func (t *target) Addr() string {
return t.getAddr()
}
func (t *target) Client() api.IonscaleServiceClient {
return t.client
}
func (t *target) TailnetID() uint64 {
if t.tailnet == nil {
return 0
}
return t.tailnet.Id
}
+15 -41
View File
@@ -1,16 +1,15 @@
package cmd
import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"github.com/rodaine/table"
"github.com/spf13/cobra"
)
func userCommands() *coral.Command {
command := &coral.Command{
func userCommands() *cobra.Command {
command := &cobra.Command{
Use: "users",
Aliases: []string{"user"},
Short: "Manage ionscale users",
@@ -23,35 +22,16 @@ func userCommands() *coral.Command {
return command
}
func listUsersCommand() *coral.Command {
command := &coral.Command{
func listUsersCommand() *cobra.Command {
command, tc := prepareCommand(true, &cobra.Command{
Use: "list",
Short: "List users",
SilenceUsage: true,
}
})
var tailnetID uint64
var tailnetName string
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "Tailnet name. Mutually exclusive with --tailnet-id.")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "Tailnet ID. Mutually exclusive with --tailnet.")
command.PreRunE = checkRequiredTailnetAndTailnetIdFlags
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
return err
}
req := api.ListUsersRequest{TailnetId: tailnet.Id}
resp, err := client.ListUsers(context.Background(), connect.NewRequest(&req))
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.ListUsersRequest{TailnetId: tc.TailnetID()}
resp, err := tc.Client().ListUsers(cmd.Context(), connect.NewRequest(&req))
if err != nil {
return err
@@ -69,28 +49,22 @@ func listUsersCommand() *coral.Command {
return command
}
func deleteUserCommand() *coral.Command {
command := &coral.Command{
func deleteUserCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "delete",
Short: "Deletes a user",
SilenceUsage: true,
}
})
var userID uint64
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&userID, "user-id", 0, "User ID.")
_ = command.MarkFlagRequired("user-id")
command.RunE = func(command *coral.Command, args []string) error {
client, err := target.createGRPCClient()
if err != nil {
return err
}
command.RunE = func(cmd *cobra.Command, args []string) error {
req := api.DeleteUserRequest{UserId: userID}
if _, err := client.DeleteUser(context.Background(), connect.NewRequest(&req)); err != nil {
if _, err := tc.Client().DeleteUser(cmd.Context(), connect.NewRequest(&req)); err != nil {
return err
}
+7 -20
View File
@@ -1,25 +1,21 @@
package cmd
import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/ionscale/internal/version"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"github.com/spf13/cobra"
)
func versionCommand() *coral.Command {
var command = &coral.Command{
func versionCommand() *cobra.Command {
command, tc := prepareCommand(false, &cobra.Command{
Use: "version",
Short: "Display version information",
SilenceUsage: true,
}
})
var target = Target{}
target.prepareCommand(command)
command.Run = func(cmd *coral.Command, args []string) {
command.Run = func(cmd *cobra.Command, args []string) {
clientVersion, clientRevision := version.GetReleaseInfo()
fmt.Printf(`
Client:
@@ -27,16 +23,7 @@ Client:
Git Revision: %s
`, clientVersion, clientRevision)
client, err := target.createGRPCClient()
if err != nil {
fmt.Printf(`
Server:
Error: %s
`, err)
return
}
resp, err := client.GetVersion(context.Background(), connect.NewRequest(&api.GetVersionRequest{}))
resp, err := tc.Client().GetVersion(cmd.Context(), connect.NewRequest(&api.GetVersionRequest{}))
if err != nil {
fmt.Printf(`
Server:
@@ -50,7 +37,7 @@ Server:
Addr: %s
Version: %s
Git Revision: %s
`, target.getAddr(), resp.Msg.Version, resp.Msg.Revision)
`, tc.Addr(), resp.Msg.Version, resp.Msg.Revision)
}
+251 -67
View File
@@ -2,18 +2,20 @@ package config
import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/caarlos0/env/v6"
"github.com/caddyserver/certmagic"
"github.com/imdario/mergo"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/key"
"github.com/jsiebens/ionscale/internal/util"
"github.com/mitchellh/go-homedir"
"gopkg.in/yaml.v3"
"net/url"
"os"
"path/filepath"
"regexp"
"sigs.k8s.io/yaml"
"strings"
"tailscale.com/tailcfg"
tkey "tailscale.com/types/key"
"time"
)
@@ -60,6 +62,11 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
b, err = expandEnvVars(b)
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(b, cfg); err != nil {
return nil, err
}
@@ -67,60 +74,62 @@ func LoadConfig(path string) (*Config, error) {
envCfgB64 := os.Getenv("IONSCALE_CONFIG_BASE64")
if len(envCfgB64) != 0 {
b, err := base64.RawStdEncoding.DecodeString(envCfgB64)
b, err := base64.StdEncoding.DecodeString(envCfgB64)
if err != nil {
return nil, err
}
b, err = expandEnvVars(b)
if err != nil {
return nil, err
}
// merge env configuration on top of the default/file configuration
if err := yaml.Unmarshal(b, cfg); err != nil {
return nil, err
}
}
envCfg := &Config{}
if err := env.Parse(envCfg, env.Options{Prefix: "IONSCALE_"}); err != nil {
return nil, err
}
// merge env configuration on top of the default/file configuration
if err := mergo.Merge(cfg, envCfg, mergo.WithOverride); err != nil {
return nil, err
}
keepAliveInterval = cfg.PollNet.KeepAliveInterval
keepAliveInterval = time.Duration(cfg.PollNet.KeepAliveInterval)
magicDNSSuffix = cfg.DNS.MagicDNSSuffix
if cfg.DNS.Provider.Zone != "" {
dnsProviderConfigured = true
}
return cfg, nil
return cfg.Validate()
}
func defaultConfig() *Config {
return &Config{
HttpListenAddr: ":8080",
HttpsListenAddr: ":8443",
ListenAddr: ":8080",
MetricsListenAddr: ":9091",
ServerUrl: "https://localhost:8843",
StunListenAddr: ":3478",
Database: Database{
Type: "sqlite",
Url: "./ionscale.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)",
Type: "sqlite",
Url: "./ionscale.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)",
MaxOpenConns: 0,
MaxIdleConns: 2,
},
Tls: Tls{
Disable: false,
ForceHttps: true,
AcmeEnabled: false,
AcmeCA: certmagic.LetsEncryptProductionCA,
AcmePath: "./acme",
},
PollNet: PollNet{
KeepAliveInterval: defaultKeepAliveInterval,
KeepAliveInterval: Duration(defaultKeepAliveInterval),
},
DNS: DNS{
MagicDNSSuffix: defaultMagicDNSSuffix,
},
DERP: DERP{
Server: DERPServer{
Disabled: false,
RegionID: 1000,
RegionCode: "ionscale",
RegionName: "ionscale Embedded DERP",
},
},
Logging: Logging{
Level: "info",
},
@@ -134,83 +143,136 @@ type ServerKeys struct {
}
type Config struct {
HttpListenAddr string `yaml:"http_listen_addr,omitempty" env:"HTTP_LISTEN_ADDR"`
HttpsListenAddr string `yaml:"https_listen_addr,omitempty" env:"HTTPS_LISTEN_ADDR"`
MetricsListenAddr string `yaml:"metrics_listen_addr,omitempty" env:"METRICS_LISTEN_ADDR"`
ServerUrl string `yaml:"server_url,omitempty" env:"SERVER_URL"`
Tls Tls `yaml:"tls,omitempty" envPrefix:"TLS_"`
PollNet PollNet `yaml:"poll_net,omitempty" envPrefix:"POLL_NET_"`
Keys Keys `yaml:"keys,omitempty" envPrefix:"KEYS_"`
Database Database `yaml:"database,omitempty" envPrefix:"DB_"`
Auth Auth `yaml:"auth,omitempty" envPrefix:"AUTH_"`
DNS DNS `yaml:"dns,omitempty"`
Logging Logging `yaml:"logging,omitempty" envPrefix:"LOGGING_"`
ListenAddr string `json:"listen_addr,omitempty"`
StunListenAddr string `json:"stun_listen_addr,omitempty"`
MetricsListenAddr string `json:"metrics_listen_addr,omitempty"`
PublicAddr string `json:"public_addr,omitempty"`
StunPublicAddr string `json:"stun_public_addr,omitempty"`
Tls Tls `json:"tls,omitempty"`
PollNet PollNet `json:"poll_net,omitempty"`
Keys Keys `json:"keys,omitempty"`
Database Database `json:"database,omitempty"`
Auth Auth `json:"auth,omitempty"`
DNS DNS `json:"dns,omitempty"`
DERP DERP `json:"derp,omitempty"`
Logging Logging `json:"logging,omitempty"`
PublicUrl *url.URL `json:"-"`
stunHost string
stunPort int
derpHost string
derpPort int
}
type Tls struct {
Disable bool `yaml:"disable" env:"DISABLE"`
ForceHttps bool `yaml:"force_https" env:"FORCE_HTTPS"`
CertFile string `yaml:"cert_file,omitempty" env:"CERT_FILE"`
KeyFile string `yaml:"key_file,omitempty" env:"KEY_FILE"`
AcmeEnabled bool `yaml:"acme,omitempty" env:"ACME_ENABLED"`
AcmeEmail string `yaml:"acme_email,omitempty" env:"ACME_EMAIL"`
AcmeCA string `yaml:"acme_ca,omitempty" env:"ACME_CA"`
AcmePath string `yaml:"acme_path,omitempty" env:"ACME_PATH"`
Disable bool `json:"disable"`
ForceHttps bool `json:"force_https"`
CertFile string `json:"cert_file,omitempty"`
KeyFile string `json:"key_file,omitempty"`
AcmeEnabled bool `json:"acme,omitempty"`
AcmeEmail string `json:"acme_email,omitempty"`
AcmeCA string `json:"acme_ca,omitempty"`
}
type PollNet struct {
KeepAliveInterval time.Duration `yaml:"keep_alive_interval" env:"KEEP_ALIVE_INTERVAL"`
KeepAliveInterval Duration `json:"keep_alive_interval"`
}
type Logging struct {
Level string `yaml:"level,omitempty" env:"LEVEL"`
Format string `yaml:"format,omitempty" env:"FORMAT"`
File string `yaml:"file,omitempty" env:"FILE"`
Level string `json:"level,omitempty"`
Format string `json:"format,omitempty"`
File string `json:"file,omitempty"`
}
type Database struct {
Type string `yaml:"type,omitempty" env:"TYPE"`
Url string `yaml:"url,omitempty" env:"URL"`
Type string `json:"type,omitempty"`
Url string `json:"url,omitempty"`
MaxOpenConns int `json:"max_open_conns,omitempty"`
MaxIdleConns int `json:"max_idle_conns,omitempty"`
ConnMaxLifetime Duration `json:"conn_max_life_time,omitempty"`
ConnMaxIdleTime Duration `json:"conn_max_idle_time,omitempty"`
}
type Keys struct {
ControlKey string `yaml:"control_key,omitempty" env:"CONTROL_KEY"`
LegacyControlKey string `yaml:"legacy_control_key,omitempty" env:"LEGACY_CONTROL_KEY"`
SystemAdminKey string `yaml:"system_admin_key,omitempty" env:"SYSTEM_ADMIN_KEY"`
ControlKey string `json:"control_key,omitempty"`
LegacyControlKey string `json:"legacy_control_key,omitempty"`
SystemAdminKey string `json:"system_admin_key,omitempty"`
}
type Auth struct {
Provider AuthProvider `yaml:"provider,omitempty" envPrefix:"PROVIDER_"`
SystemAdminPolicy SystemAdminPolicy `yaml:"system_admins"`
Provider AuthProvider `json:"provider,omitempty"`
SystemAdminPolicy SystemAdminPolicy `json:"system_admins"`
}
type AuthProvider struct {
Issuer string `yaml:"issuer" env:"ISSUER"`
ClientID string `yaml:"client_id" env:"CLIENT_ID"`
ClientSecret string `yaml:"client_secret" env:"CLIENT_SECRET"`
Scopes []string `yaml:"additional_scopes" env:"SCOPES"`
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Scopes []string `json:"additional_scopes" `
}
type DNS struct {
MagicDNSSuffix string `yaml:"magic_dns_suffix"`
Provider DNSProvider `yaml:"provider,omitempty"`
MagicDNSSuffix string `json:"magic_dns_suffix"`
Provider DNSProvider `json:"provider,omitempty"`
}
type DNSProvider struct {
Name string `yaml:"name"`
Zone string `yaml:"zone"`
Configuration map[string]string `yaml:"config"`
Name string `json:"name"`
PluginPath string `json:"plugin_path"`
Zone string `json:"zone"`
Configuration json.RawMessage `json:"config"`
}
type SystemAdminPolicy struct {
Subs []string `yaml:"subs,omitempty"`
Emails []string `yaml:"emails,omitempty"`
Filters []string `yaml:"filters,omitempty"`
Subs []string `json:"subs,omitempty"`
Emails []string `json:"emails,omitempty"`
Filters []string `json:"filters,omitempty"`
}
type DERP struct {
Server DERPServer `json:"server,omitempty"`
Sources []string `json:"sources,omitempty"`
}
type DERPServer struct {
Disabled bool `json:"disabled,omitempty"`
RegionID int `json:"region_id,omitempty"`
RegionCode string `json:"region_code,omitempty"`
RegionName string `json:"region_name,omitempty"`
}
func (c *Config) Validate() (*Config, error) {
publicWebUrl, webHost, webPort, err := validatePublicAddr(c.PublicAddr)
if err != nil {
return nil, fmt.Errorf("web public addr: %w", err)
}
c.PublicUrl = publicWebUrl
c.derpHost = webHost
c.derpPort = webPort
if !c.DERP.Server.Disabled {
_, stunHost, stunPort, err := validatePublicAddr(c.StunPublicAddr)
if err != nil {
return nil, fmt.Errorf("stun public addr: %w", err)
}
c.stunHost = stunHost
c.stunPort = stunPort
}
return c, nil
}
func (c *Config) CreateUrl(format string, a ...interface{}) string {
path := fmt.Sprintf(format, a...)
return strings.TrimSuffix(c.ServerUrl, "/") + "/" + strings.TrimPrefix(path, "/")
u := url.URL{
Scheme: c.PublicUrl.Scheme,
Host: c.PublicUrl.Host,
Path: path,
}
return u.String()
}
func (c *Config) ReadServerKeys(defaultKeys *domain.ControlKeys) (*ServerKeys, error) {
@@ -246,3 +308,125 @@ func (c *Config) ReadServerKeys(defaultKeys *domain.ControlKeys) (*ServerKeys, e
return keys, nil
}
func (c *Config) DefaultDERPMap() *tailcfg.DERPMap {
if c.derpHost == c.stunHost {
return &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
c.DERP.Server.RegionID: {
RegionID: c.DERP.Server.RegionID,
RegionCode: c.DERP.Server.RegionCode,
RegionName: c.DERP.Server.RegionName,
Nodes: []*tailcfg.DERPNode{
{
RegionID: c.DERP.Server.RegionID,
Name: "ionscale",
HostName: c.derpHost,
DERPPort: c.derpPort,
STUNPort: c.stunPort,
},
},
},
},
}
}
return &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
c.DERP.Server.RegionID: {
RegionID: c.DERP.Server.RegionID,
RegionCode: c.DERP.Server.RegionCode,
RegionName: c.DERP.Server.RegionName,
Nodes: []*tailcfg.DERPNode{
{
RegionID: c.DERP.Server.RegionID,
Name: "stun",
HostName: c.stunHost,
STUNOnly: true,
STUNPort: c.stunPort,
},
{
RegionID: c.DERP.Server.RegionID,
Name: "derp",
HostName: c.derpHost,
DERPPort: c.derpPort,
STUNPort: -1,
},
},
},
},
}
}
type Duration time.Duration
func (d Duration) Std() time.Duration {
return time.Duration(d)
}
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
func (d *Duration) UnmarshalJSON(b []byte) error {
var v interface{}
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case float64:
*d = Duration(value)
return nil
case string:
tmp, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = Duration(tmp)
return nil
default:
return fmt.Errorf("invalid duration")
}
}
// Match ${VAR:default} syntax for variables with default values
var optionalEnvRegex = regexp.MustCompile(`\${([a-zA-Z0-9_]+):([^}]*)}`)
// Match ${VAR} syntax (without default) - these are required
var requiredEnvRegex = regexp.MustCompile(`\${([a-zA-Z0-9_]+)}`)
func expandEnvVars(config []byte) ([]byte, error) {
var result = config
var missingVars []string
result = optionalEnvRegex.ReplaceAllFunc(result, func(match []byte) []byte {
parts := optionalEnvRegex.FindSubmatch(match)
envVar := string(parts[1])
defaultValue := parts[2]
envValue := os.Getenv(envVar)
if envValue != "" {
return []byte(envValue)
}
return defaultValue
})
result = requiredEnvRegex.ReplaceAllFunc(result, func(match []byte) []byte {
parts := requiredEnvRegex.FindSubmatch(match)
envVar := string(parts[1])
envValue := os.Getenv(envVar)
if envValue == "" {
missingVars = append(missingVars, envVar)
return match
}
return []byte(envValue)
})
if len(missingVars) > 0 {
return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missingVars, ", "))
}
return result, nil
}
+130
View File
@@ -0,0 +1,130 @@
package config
import (
"bytes"
"github.com/stretchr/testify/require"
"os"
"testing"
)
func TestLoadConfig(t *testing.T) {
// Create a temporary config file
tempFile, err := os.CreateTemp("", "config-*.yaml")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer os.Remove(tempFile.Name())
// Write test configuration
yamlContent := `
public_addr: "ionscale.localtest.me:443"
stun_public_addr: "ionscale.localtest.me:3478"
database:
type: ${DB_TYPE:sqlite}
url: ${DB_URL}
max_open_conns: ${DB_MAX_OPEN_CONNS:5}
conn_max_life_time: ${DB_CONN_MAX_LIFE_TIME:5s}
`
if _, err := tempFile.Write([]byte(yamlContent)); err != nil {
t.Fatalf("Failed to write to temp file: %v", err)
}
tempFile.Close()
t.Run("With DB_URL set", func(t *testing.T) {
require.NoError(t, os.Setenv("DB_URL", "./ionscale.db"))
config, err := LoadConfig(tempFile.Name())
require.NoError(t, err)
require.Equal(t, "sqlite", config.Database.Type)
require.Equal(t, "./ionscale.db", config.Database.Url)
require.Equal(t, 5, config.Database.MaxOpenConns)
})
t.Run("Without required DB_URL", func(t *testing.T) {
require.NoError(t, os.Unsetenv("DB_URL"))
_, err := LoadConfig(tempFile.Name())
require.Error(t, err)
})
}
func TestExpandEnvVars(t *testing.T) {
// Setup test environment variables
require.NoError(t, os.Setenv("TEST_VAR", "test_value"))
require.NoError(t, os.Setenv("PORT", "9090"))
// Ensure TEST_DEFAULT is not set
require.NoError(t, os.Unsetenv("TEST_DEFAULT"))
tests := []struct {
name string
input []byte
expected []byte
expectError bool
}{
{
name: "Braced variable",
input: []byte("Port: ${PORT}"),
expected: []byte("Port: 9090"),
expectError: false,
},
{
name: "Default value used",
input: []byte("Default: ${TEST_DEFAULT:fallback}"),
expected: []byte("Default: fallback"),
expectError: false,
},
{
name: "Default value not used when env var exists",
input: []byte("Not default: ${PORT:8080}"),
expected: []byte("Not default: 9090"),
expectError: false,
},
{
name: "Multiple replacements",
input: []byte("Config: ${TEST_VAR} ${PORT} ${TEST_DEFAULT:default}"),
expected: []byte("Config: test_value 9090 default"),
expectError: false,
},
{
name: "Missing required variable",
input: []byte("Required: ${MISSING_VAR}"),
expected: nil,
expectError: true,
},
{
name: "Mixed variables with one missing",
input: []byte("Mixed: ${TEST_VAR} ${MISSING_VAR} ${TEST_DEFAULT:default}"),
expected: nil,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := expandEnvVars(tt.input)
// Check error expectation
if tt.expectError && err == nil {
t.Errorf("expandEnvVars() expected error but got none")
return
}
if !tt.expectError && err != nil {
t.Errorf("expandEnvVars() got unexpected error: %v", err)
return
}
// If we expected an error, don't check the result further
if tt.expectError {
return
}
// Check result
if !bytes.Equal(result, tt.expected) {
t.Errorf("expandEnvVars() got = %s, want %s", result, tt.expected)
}
})
}
}
+34
View File
@@ -1,7 +1,11 @@
package config
import (
"fmt"
"net"
"net/url"
"os"
"strconv"
"strings"
)
@@ -20,3 +24,33 @@ func GetString(key, defaultValue string) string {
}
return defaultValue
}
func validatePublicAddr(addr string) (*url.URL, string, int, error) {
scheme := "https"
if strings.HasPrefix(addr, "http://") {
scheme = "http"
addr = strings.TrimPrefix(addr, "http://")
}
if strings.HasPrefix(addr, "https://") {
scheme = "https"
addr = strings.TrimPrefix(addr, "https://")
}
host, portS, err := net.SplitHostPort(addr)
if err != nil {
return nil, "", -1, fmt.Errorf("invalid")
}
port, err := strconv.Atoi(portS)
if err != nil {
return nil, "", 0, fmt.Errorf("invalid")
}
if (port == 443 && scheme == "https") || (port == 80 && scheme == "http") {
return &url.URL{Scheme: scheme, Host: host}, host, port, nil
}
return &url.URL{Scheme: scheme, Host: fmt.Sprintf("%s:%d", host, port)}, host, port, nil
}
+36
View File
@@ -0,0 +1,36 @@
package config
import (
"fmt"
"github.com/stretchr/testify/require"
"net/url"
"testing"
)
func TestPublicAddrToUrl(t *testing.T) {
mustParseUrl := func(s string) *url.URL {
parse, err := url.Parse(s)
require.NoError(t, err)
return parse
}
parameters := []struct {
input string
expected *url.URL
err error
}{
{"localtest.me", nil, fmt.Errorf("invalid")},
{"localtest.me:443", mustParseUrl("https://localtest.me"), nil},
{"localtest.me:80", mustParseUrl("https://localtest.me:80"), nil},
{"localtest.me:8080", mustParseUrl("https://localtest.me:8080"), nil},
{"http://localtest.me:8080", mustParseUrl("http://localtest.me:8080"), nil},
}
for _, p := range parameters {
t.Run(fmt.Sprintf("Testing [%v]", p.input), func(t *testing.T) {
url, _, _, err := validatePublicAddr(p.input)
require.Equal(t, p.expected, url)
require.Equal(t, p.err, err)
})
}
}
+82 -40
View File
@@ -1,6 +1,8 @@
package core
import (
"github.com/puzpuzpuz/xsync/v3"
"slices"
"sync"
"time"
)
@@ -8,50 +10,109 @@ import (
type Ping struct{}
type PollMapSessionManager interface {
Register(tailnetID uint64, machineID uint64, ch chan *Ping)
Deregister(tailnetID uint64, machineID uint64)
Register(tailnetID uint64, machineID uint64, ch chan<- *Ping)
Deregister(tailnetID uint64, machineID uint64, ch chan<- *Ping)
HasSession(tailnetID uint64, machineID uint64) bool
NotifyAll(tailnetID uint64)
NotifyAll(tailnetID uint64, ignoreMachineIDs ...uint64)
}
func NewPollMapSessionManager() PollMapSessionManager {
return &pollMapSessionManager{
data: map[uint64]map[uint64]chan *Ping{},
timers: map[uint64]*time.Timer{},
tailnets: xsync.NewMapOf[uint64, *tailnetSessionManager](),
}
}
type pollMapSessionManager struct {
sync.RWMutex
data map[uint64]map[uint64]chan *Ping
timers map[uint64]*time.Timer
tailnets *xsync.MapOf[uint64, *tailnetSessionManager]
}
func (n *pollMapSessionManager) Register(tailnetID uint64, machineID uint64, ch chan *Ping) {
func (n *pollMapSessionManager) load(tailnetID uint64) *tailnetSessionManager {
m, _ := n.tailnets.LoadOrCompute(tailnetID, func() *tailnetSessionManager {
return &tailnetSessionManager{
targets: make(map[uint64]chan<- *Ping),
timers: make(map[uint64]*time.Timer),
sessions: xsync.NewMapOf[uint64, bool](),
}
})
return m
}
func (n *pollMapSessionManager) Register(tailnetID uint64, machineID uint64, ch chan<- *Ping) {
n.load(tailnetID).Register(machineID, ch)
}
func (n *pollMapSessionManager) Deregister(tailnetID uint64, machineID uint64, ch chan<- *Ping) {
n.load(tailnetID).Deregister(machineID, ch)
}
func (n *pollMapSessionManager) HasSession(tailnetID uint64, machineID uint64) bool {
return n.load(tailnetID).HasSession(machineID)
}
func (n *pollMapSessionManager) NotifyAll(tailnetID uint64, ignoreMachineIDs ...uint64) {
n.load(tailnetID).NotifyAll(ignoreMachineIDs...)
}
type tailnetSessionManager struct {
sync.RWMutex
targets map[uint64]chan<- *Ping
timers map[uint64]*time.Timer
sessions *xsync.MapOf[uint64, bool]
}
func (n *tailnetSessionManager) NotifyAll(ignoreMachineIDs ...uint64) {
n.RLock()
defer n.RUnlock()
for i, p := range n.targets {
if !slices.Contains(ignoreMachineIDs, i) {
select {
case p <- &Ping{}:
default: // ignore, channel has a small buffer, failing to insert means there is already a ping pending
}
}
}
}
func (n *tailnetSessionManager) Register(machineID uint64, ch chan<- *Ping) {
n.Lock()
defer n.Unlock()
if ss := n.data[tailnetID]; ss == nil {
n.data[tailnetID] = map[uint64]chan *Ping{machineID: ch}
} else {
ss[machineID] = ch
if curr, ok := n.targets[machineID]; ok {
close(curr)
}
n.targets[machineID] = ch
n.sessions.Store(machineID, true)
t, ok := n.timers[machineID]
if ok {
t.Stop()
delete(n.timers, machineID)
}
timer := time.NewTimer(5 * time.Second)
go func() {
<-timer.C
if n.HasSession(machineID) {
n.NotifyAll(machineID)
}
}()
n.timers[machineID] = timer
}
func (n *pollMapSessionManager) Deregister(tailnetID uint64, machineID uint64) {
func (n *tailnetSessionManager) Deregister(machineID uint64, ch chan<- *Ping) {
n.Lock()
defer n.Unlock()
if ss := n.data[tailnetID]; ss != nil {
delete(ss, machineID)
if curr, ok := n.targets[machineID]; ok && curr != ch {
return
}
delete(n.targets, machineID)
n.sessions.Store(machineID, false)
t, ok := n.timers[machineID]
if ok {
t.Stop()
@@ -61,34 +122,15 @@ func (n *pollMapSessionManager) Deregister(tailnetID uint64, machineID uint64) {
timer := time.NewTimer(10 * time.Second)
go func() {
<-timer.C
if !n.HasSession(tailnetID, machineID) {
n.NotifyAll(tailnetID)
if !n.HasSession(machineID) {
n.NotifyAll()
}
}()
n.timers[machineID] = timer
}
func (n *pollMapSessionManager) HasSession(tailnetID uint64, machineID uint64) bool {
n.RLock()
defer n.RUnlock()
if ss := n.data[tailnetID]; ss != nil {
if _, ok := ss[machineID]; ok {
return true
}
}
return false
}
func (n *pollMapSessionManager) NotifyAll(tailnetID uint64) {
n.RLock()
defer n.RUnlock()
if ss := n.data[tailnetID]; ss != nil {
for _, p := range ss {
p <- &Ping{}
}
}
func (n *tailnetSessionManager) HasSession(machineID uint64) bool {
v, ok := n.sessions.Load(machineID)
return ok && v
}
@@ -11,8 +11,8 @@ const (
inactivityTimeout = 30 * time.Minute
)
func StartReaper(repository domain.Repository, sessionManager PollMapSessionManager) {
r := &reaper{
func StartWorker(repository domain.Repository, sessionManager PollMapSessionManager) {
r := &worker{
sessionManager: sessionManager,
repository: repository,
}
@@ -20,19 +20,20 @@ func StartReaper(repository domain.Repository, sessionManager PollMapSessionMana
go r.start()
}
type reaper struct {
type worker struct {
sessionManager PollMapSessionManager
repository domain.Repository
}
func (r *reaper) start() {
func (r *worker) start() {
r.deleteInactiveEphemeralNodes()
t := time.NewTicker(ticker)
for range t.C {
r.reapInactiveEphemeralNodes()
r.deleteInactiveEphemeralNodes()
}
}
func (r *reaper) reapInactiveEphemeralNodes() {
func (r *worker) deleteInactiveEphemeralNodes() {
ctx := context.Background()
now := time.Now().UTC()
@@ -41,6 +42,7 @@ func (r *reaper) reapInactiveEphemeralNodes() {
if err != nil {
return
}
var removedNodes = make(map[uint64][]uint64)
for _, m := range machines {
if now.After(m.LastSeen.Add(inactivityTimeout)) {
+36 -24
View File
@@ -2,12 +2,13 @@ package database
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/go-gormigrate/gormigrate/v2"
"github.com/hashicorp/go-hclog"
"github.com/jsiebens/ionscale/internal/database/migration"
"github.com/jsiebens/ionscale/internal/util"
"go.uber.org/zap"
"tailscale.com/types/key"
"time"
@@ -15,37 +16,48 @@ import (
"github.com/jsiebens/ionscale/internal/domain"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/plugin/prometheus"
)
type db interface {
DB() *gorm.DB
type dbLock interface {
Lock() error
Unlock() error
UnlockErr(error) error
}
func OpenDB(config *config.Database, logger hclog.Logger) (domain.Repository, error) {
db, err := createDB(config, logger)
func OpenDB(config *config.Database, logger *zap.Logger) (*sql.DB, domain.Repository, error) {
db, lock, err := createDB(config, logger)
if err != nil {
return nil, err
return nil, nil, err
}
repository := domain.NewRepository(db.DB())
_ = db.Use(prometheus.New(prometheus.Config{StartServer: false}))
if err := db.Lock(); err != nil {
return nil, err
sqlDB, err := db.DB()
if err != nil {
return nil, nil, err
}
if err := db.UnlockErr(migrate(db.DB())); err != nil {
return nil, err
sqlDB.SetMaxOpenConns(config.MaxOpenConns)
sqlDB.SetMaxIdleConns(config.MaxIdleConns)
sqlDB.SetConnMaxLifetime(config.ConnMaxLifetime.Std())
sqlDB.SetConnMaxIdleTime(config.ConnMaxIdleTime.Std())
repository := domain.NewRepository(db)
if err := lock.Lock(); err != nil {
return nil, nil, err
}
return repository, nil
if err := lock.UnlockErr(migrate(db)); err != nil {
return nil, nil, err
}
return sqlDB, repository, nil
}
func createDB(config *config.Database, logger hclog.Logger) (db, error) {
func createDB(config *config.Database, logger *zap.Logger) (*gorm.DB, dbLock, error) {
gormConfig := &gorm.Config{
Logger: &GormLoggerAdapter{logger: logger.Named("db")},
Logger: &GormLoggerAdapter{logger: logger.Sugar()},
}
switch config.Type {
@@ -55,7 +67,7 @@ func createDB(config *config.Database, logger hclog.Logger) (db, error) {
return newPostgresDB(config, gormConfig)
}
return nil, fmt.Errorf("invalid database type '%s'", config.Type)
return nil, nil, fmt.Errorf("invalid database type '%s'", config.Type)
}
func migrate(db *gorm.DB) error {
@@ -123,7 +135,7 @@ func createJSONWebKeySet(ctx context.Context, repository domain.Repository) erro
}
type GormLoggerAdapter struct {
logger hclog.Logger
logger *zap.SugaredLogger
}
func (g *GormLoggerAdapter) LogMode(level logger.LogLevel) logger.Interface {
@@ -131,11 +143,11 @@ func (g *GormLoggerAdapter) LogMode(level logger.LogLevel) logger.Interface {
}
func (g *GormLoggerAdapter) Info(ctx context.Context, s string, i ...interface{}) {
g.logger.Info(s, i)
g.logger.Infow(s, i)
}
func (g *GormLoggerAdapter) Warn(ctx context.Context, s string, i ...interface{}) {
g.logger.Warn(s, i)
g.logger.Warnw(s, i)
}
func (g *GormLoggerAdapter) Error(ctx context.Context, s string, i ...interface{}) {
@@ -143,22 +155,22 @@ func (g *GormLoggerAdapter) Error(ctx context.Context, s string, i ...interface{
}
func (g *GormLoggerAdapter) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
if g.logger.IsTrace() {
if g.logger.Level().Enabled(zap.DebugLevel) {
elapsed := time.Since(begin)
switch {
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound):
sql, rows := fc()
if rows == -1 {
g.logger.Trace("Error executing query", "sql", sql, "start_time", begin.Format(time.RFC3339), "duration", elapsed, "err", err)
g.logger.Debugw("Error executing query", "sql", sql, "start_time", begin.Format(time.RFC3339), "duration", elapsed, "err", err)
} else {
g.logger.Trace("Error executing query", "sql", sql, "start_time", begin.Format(time.RFC3339), "duration", elapsed, "rows", rows, "err", err)
g.logger.Debugw("Error executing query", "sql", sql, "start_time", begin.Format(time.RFC3339), "duration", elapsed, "rows", rows, "err", err)
}
default:
sql, rows := fc()
if rows == -1 {
g.logger.Trace("Statement executed", "sql", sql, "start_time", begin.Format(time.RFC3339), "duration", elapsed)
g.logger.Debugw("Statement executed", "sql", sql, "start_time", begin.Format(time.RFC3339), "duration", elapsed)
} else {
g.logger.Trace("Statement executed", "sql", sql, "start_time", begin.Format(time.RFC3339), "duration", elapsed, "rows", rows)
g.logger.Debugw("Statement executed", "sql", sql, "start_time", begin.Format(time.RFC3339), "duration", elapsed, "rows", rows)
}
}
}
@@ -0,0 +1,32 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"github.com/jsiebens/ionscale/internal/domain"
"gorm.io/gorm"
)
func m202212270800_machine_indeces() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202212270800",
Migrate: func(db *gorm.DB) error {
type Machine struct {
ID uint64 `gorm:"primaryKey;autoIncrement:false;index:idx_tailnet_id_id,priority:2"`
MachineKey string `gorm:"index:idx_machine_keys"`
NodeKey string `gorm:"index:idx_machine_keys"`
Name string `gorm:"index:idx_tailnet_id_name,priority:2"`
NameIdx uint64 `gorm:"index:idx_tailnet_id_name,sort:desc,priority:3"`
TailnetID uint64 `gorm:"index:idx_tailnet_id_id,priority:1;index:idx_tailnet_id_name,priority:1"`
IPv4 domain.IP `gorm:"index:idx_ipv4"`
}
return db.AutoMigrate(
&Machine{},
)
},
Rollback: nil,
}
}
@@ -0,0 +1,23 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
"time"
)
func m202312271200_account_last_authenticated() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202312271200",
Migrate: func(db *gorm.DB) error {
type Account struct {
LastAuthenticated *time.Time
}
return db.AutoMigrate(
&Account{},
)
},
Rollback: nil,
}
}
@@ -0,0 +1,25 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func m202312290900_machine_indeces() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202312290900",
Migrate: func(db *gorm.DB) error {
type Machine struct {
Name string `gorm:"index:idx_tailnet_id_name,unique,priority:2"`
NameIdx uint64 `gorm:"index:idx_tailnet_id_name,unique,sort:desc,priority:3"`
}
db.Migrator().DropIndex(&Machine{}, "idx_tailnet_id_name")
return db.AutoMigrate(
&Machine{},
)
},
Rollback: nil,
}
}
@@ -0,0 +1,27 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func m202401061400_machine_indeces() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202401061400",
Migrate: func(db *gorm.DB) error {
type Machine struct {
ID uint64 `gorm:"primaryKey;autoIncrement:false;index:idx_tailnet_id_id,priority:2"`
Name string `gorm:"index:idx_tailnet_id_name,unique,priority:2"`
NameIdx uint64 `gorm:"index:idx_tailnet_id_name,unique,sort:desc,priority:3"`
TailnetID uint64 `gorm:"index:idx_tailnet_id_id,priority:1;index:idx_tailnet_id_name,priority:1"`
}
db.Migrator().DropIndex(&Machine{}, "idx_tailnet_id_name")
return db.AutoMigrate(
&Machine{},
)
},
Rollback: nil,
}
}
@@ -0,0 +1,23 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
"time"
)
func m202402120800_user_last_authenticated() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202402120800",
Migrate: func(db *gorm.DB) error {
type User struct {
LastAuthenticated *time.Time
}
return db.AutoMigrate(
&User{},
)
},
Rollback: nil,
}
}
@@ -0,0 +1,29 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func m202403130830_json_to_text() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202403130830",
Migrate: func(db *gorm.DB) error {
type Tailnet struct {
IAMPolicy string
ACLPolicy string
}
if err := db.Migrator().AlterColumn(&Tailnet{}, "IAMPolicy"); err != nil {
return err
}
if err := db.Migrator().AlterColumn(&Tailnet{}, "ACLPolicy"); err != nil {
return err
}
return nil
},
Rollback: nil,
}
}
@@ -0,0 +1,24 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func m202502150830_use_hostname() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202502150830",
Migrate: func(db *gorm.DB) error {
type Machine struct {
UseOSHostname bool `gorm:"default:true"`
}
if err := db.Migrator().AddColumn(&Machine{}, "UseOSHostname"); err != nil {
return err
}
return nil
},
Rollback: nil,
}
}
@@ -15,6 +15,13 @@ func Migrations() []*gormigrate.Migration {
m202210080700_ssh_action_request(),
m202211031100_add_authorized_column(),
m202212201300_add_user_id_column(),
m202212270800_machine_indeces(),
m202312271200_account_last_authenticated(),
m202312290900_machine_indeces(),
m202401061400_machine_indeces(),
m202402120800_user_last_authenticated(),
m202403130830_json_to_text(),
m202502150830_use_hostname(),
}
return migrations
}
+14 -20
View File
@@ -11,26 +11,20 @@ import (
"gorm.io/gorm"
)
func newPostgresDB(config *config.Database, g *gorm.Config) (db, error) {
func newPostgresDB(config *config.Database, g *gorm.Config) (*gorm.DB, dbLock, error) {
db, err := gorm.Open(postgres.Open(config.Url), g)
if err != nil {
return nil, err
return nil, nil, err
}
return &Postgres{
db: db,
}, nil
return db, &pgLock{db: db}, nil
}
type Postgres struct {
type pgLock struct {
db *gorm.DB
}
func (s *Postgres) DB() *gorm.DB {
return s.db
}
func (s *Postgres) Lock() error {
func (s *pgLock) Lock() error {
d, _ := s.db.DB()
query := `SELECT pg_advisory_lock($1)`
@@ -42,7 +36,14 @@ func (s *Postgres) Lock() error {
return nil
}
func (s *Postgres) Unlock() error {
func (s *pgLock) UnlockErr(prevErr error) error {
if err := s.unlock(); err != nil {
return multierror.Append(prevErr, err)
}
return prevErr
}
func (s *pgLock) unlock() error {
d, _ := s.db.DB()
query := `SELECT pg_advisory_unlock($1)`
@@ -53,16 +54,9 @@ func (s *Postgres) Unlock() error {
return nil
}
func (s *Postgres) UnlockErr(prevErr error) error {
if err := s.Unlock(); err != nil {
return multierror.Append(prevErr, err)
}
return prevErr
}
const advisoryLockIDSalt uint = 1486364155
func (s *Postgres) generateAdvisoryLockId() string {
func (s *pgLock) generateAdvisoryLockId() string {
sum := crc32.ChecksumIEEE([]byte("ionscale_migration"))
sum = sum * uint32(advisoryLockIDSalt)
return fmt.Sprint(sum)
+6 -18
View File
@@ -6,33 +6,21 @@ import (
"gorm.io/gorm"
)
func newSqliteDB(config *config.Database, g *gorm.Config) (db, error) {
func newSqliteDB(config *config.Database, g *gorm.Config) (*gorm.DB, dbLock, error) {
db, err := gorm.Open(sqlite.Open(config.Url), g)
if err != nil {
return nil, err
return nil, nil, err
}
return &Sqlite{
db: db,
}, nil
return db, &sqliteLock{}, nil
}
type Sqlite struct {
db *gorm.DB
type sqliteLock struct {
}
func (s *Sqlite) DB() *gorm.DB {
return s.db
}
func (s *Sqlite) Lock() error {
func (s *sqliteLock) Lock() error {
return nil
}
func (s *Sqlite) Unlock() error {
return nil
}
func (s *Sqlite) UnlockErr(prevErr error) error {
func (s *sqliteLock) UnlockErr(prevErr error) error {
return prevErr
}
+63
View File
@@ -0,0 +1,63 @@
package derp
import (
"encoding/json"
"github.com/hashicorp/go-getter"
"github.com/hashicorp/go-multierror"
"github.com/jsiebens/ionscale/internal/config"
"os"
"tailscale.com/tailcfg"
)
func LoadDERPSources(c *config.Config) (*tailcfg.DERPMap, error) {
derpMap := &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{},
}
var merr *multierror.Error
for _, src := range c.DERP.Sources {
dm, err := loadDERPSource(src)
if err != nil {
merr = multierror.Append(merr, err)
continue
}
for id, r := range dm.Regions {
derpMap.Regions[id] = r
}
}
if !c.DERP.Server.Disabled {
dm := c.DefaultDERPMap()
for id, r := range dm.Regions {
derpMap.Regions[id] = r
}
}
return derpMap, merr.ErrorOrNil()
}
func loadDERPSource(src string) (*tailcfg.DERPMap, error) {
temp, err := os.CreateTemp(os.TempDir(), "derp-*.json")
if err != nil {
return nil, err
}
defer os.Remove(temp.Name())
if err := getter.Get(temp.Name(), src, getter.WithMode(getter.ClientModeFile)); err != nil {
return nil, err
}
content, err := os.ReadFile(temp.Name())
if err != nil {
return nil, err
}
var dm tailcfg.DERPMap
if err := json.Unmarshal(content, &dm); err != nil {
return nil, err
}
return &dm, nil
}
+124
View File
@@ -0,0 +1,124 @@
package dns
import (
"context"
"encoding/json"
"fmt"
"github.com/hashicorp/go-plugin"
"github.com/jsiebens/ionscale/internal/util"
dnsplugin "github.com/jsiebens/libdns-plugin"
"github.com/libdns/libdns"
"go.uber.org/zap"
"os/exec"
"sync"
"time"
)
// pluginManager handles plugin lifecycle and resilience
type pluginManager struct {
pluginPath string
client *plugin.Client
instance dnsplugin.Provider
lock sync.RWMutex
logger *zap.Logger
zone string
config json.RawMessage
}
// NewPluginManager creates a new plugin manager
func newPluginManager(pluginPath string, zone string, config json.RawMessage) (*pluginManager, error) {
logger := zap.L().Named("dns").With(zap.String("plugin_path", pluginPath))
p := &pluginManager{
pluginPath: pluginPath,
logger: logger,
zone: zone,
config: config,
}
if err := p.ensureRunning(true); err != nil {
return nil, err
}
return p, nil
}
// ensureRunning makes sure the plugin is running
func (pm *pluginManager) ensureRunning(start bool) error {
pm.lock.RLock()
running := pm.client != nil && !pm.client.Exited()
instance := pm.instance
pm.lock.RUnlock()
if running && instance != nil {
return nil
}
// Need to restart
pm.lock.Lock()
defer pm.lock.Unlock()
if !start {
pm.logger.Info("Restarting DNS plugin")
}
if pm.client != nil {
pm.client.Kill()
}
// Create a new client
cmd := exec.Command(pm.pluginPath)
pm.client = plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: dnsplugin.Handshake,
Plugins: dnsplugin.PluginMap,
Cmd: cmd,
AllowedProtocols: []plugin.Protocol{
plugin.ProtocolNetRPC,
plugin.ProtocolGRPC,
},
Managed: true,
Logger: util.NewZapAdapter(pm.logger, "dns"),
})
// Connect via RPC
rpcClient, err := pm.client.Client()
if err != nil {
return fmt.Errorf("error creating plugin client: %w", err)
}
// Request the plugin
raw, err := rpcClient.Dispense(dnsplugin.ProviderPluginName)
if err != nil {
return fmt.Errorf("error dispensing plugin: %w", err)
}
// Convert to the interface
pm.instance = raw.(dnsplugin.Provider)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
if err := pm.instance.Configure(ctx, pm.config); err != nil {
return fmt.Errorf("error configuring plugin: %w", err)
}
pm.logger.Info("DNS plugin started")
return nil
}
func (pm *pluginManager) SetRecord(ctx context.Context, recordType, recordName, value string) error {
if err := pm.ensureRunning(false); err != nil {
return err
}
_, err := pm.instance.SetRecords(ctx, pm.zone, []libdns.Record{{
Type: recordType,
Name: libdns.RelativeName(recordName, pm.zone),
Value: value,
TTL: 1 * time.Minute,
}})
return err
}
+52 -106
View File
@@ -2,20 +2,28 @@ package dns
import (
"context"
"encoding/json"
"fmt"
"github.com/imdario/mergo"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/mapping"
"github.com/libdns/azure"
"github.com/libdns/cloudflare"
"github.com/libdns/digitalocean"
"github.com/libdns/googleclouddns"
"github.com/libdns/libdns"
"github.com/libdns/route53"
"go.uber.org/zap"
"strings"
"time"
)
var factories = map[string]func() libdns.RecordSetter{
"azure": azureProvider,
"cloudflare": cloudflareProvider,
"digitalocean": digitalOceanProvider,
"googleclouddns": googleCloudDNSProvider,
"route53": route53Provider,
}
type Provider interface {
SetRecord(ctx context.Context, recordType, recordName, value string) error
}
@@ -26,126 +34,57 @@ func NewProvider(config config.DNS) (Provider, error) {
return nil, nil
}
if p.Name == "" && p.PluginPath == "" {
return nil, fmt.Errorf("invalid dns provider configuration, either name or plugin_path should be set")
}
if p.Name != "" && p.PluginPath != "" {
return nil, fmt.Errorf("invalid dns provider configuration, only one of name or plugin_path should be set")
}
if !strings.HasSuffix(config.MagicDNSSuffix, p.Zone) {
return nil, fmt.Errorf("invalid MagicDNS suffix [%s], not part of zone [%s]", config.MagicDNSSuffix, p.Zone)
}
switch p.Name {
case "azure":
return configureAzureProvider(p.Zone, p.Configuration)
case "cloudflare":
return configureCloudflareProvider(p.Zone, p.Configuration)
case "digitalocean":
return configureDigitalOceanProvider(p.Zone, p.Configuration)
case "googleclouddns":
return configureGoogleCloudDNSProvider(p.Zone, p.Configuration)
case "route53":
return configureRoute53Provider(p.Zone, p.Configuration)
default:
return nil, fmt.Errorf("unknown dns provider: %s", p.Name)
factory, ok := factories[p.Name]
if ok {
return newProvider(p.Zone, p.Configuration, factory)
}
return newPluginManager(p.PluginPath, fqdn(p.Zone), p.Configuration)
}
func configureAzureProvider(zone string, values map[string]string) (Provider, error) {
p := &azure.Provider{}
if err := mapping.CopyViaJson(values, p); err != nil {
func newProvider(zone string, values json.RawMessage, factory func() libdns.RecordSetter) (Provider, error) {
p := factory()
if err := json.Unmarshal(values, p); err != nil {
return nil, err
}
e := &azure.Provider{
TenantId: config.GetString("IONSCALE_DNS_AZURE_TENANT_ID", ""),
ClientId: config.GetString("IONSCALE_DNS_AZURE_CLIENT_ID", ""),
ClientSecret: config.GetString("IONSCALE_DNS_AZURE_CLIENT_SECRET", ""),
SubscriptionId: config.GetString("IONSCALE_DNS_AZURE_SUBSCRIPTION_ID", ""),
ResourceGroupName: config.GetString("IONSCALE_DNS_AZURE_RESOURCE_GROUP_NAME", ""),
}
// merge env configuration on top of the default/file configuration
if err := mergo.Merge(p, e, mergo.WithOverride); err != nil {
return nil, err
}
return &externalProvider{zone: zone, setter: p}, nil
return &externalProvider{zone: fqdn(zone), setter: p}, nil
}
func configureCloudflareProvider(zone string, values map[string]string) (Provider, error) {
p := &cloudflare.Provider{}
if err := mapping.CopyViaJson(values, p); err != nil {
return nil, err
}
e := &cloudflare.Provider{
APIToken: config.GetString("IONSCALE_DNS_CLOUDFLARE_API_TOKEN", ""),
}
// merge env configuration on top of the default/file configuration
if err := mergo.Merge(p, e, mergo.WithOverride); err != nil {
return nil, err
}
return &externalProvider{zone: zone, setter: p}, nil
func azureProvider() libdns.RecordSetter {
zap.L().Warn("Builtin azure DNS plugin is deprecated and will be removed in a future release.")
return &azure.Provider{}
}
func configureDigitalOceanProvider(zone string, values map[string]string) (Provider, error) {
p := &digitalocean.Provider{}
if err := mapping.CopyViaJson(values, p); err != nil {
return nil, err
}
e := &digitalocean.Provider{
APIToken: config.GetString("IONSCALE_DNS_DIGITALOCEAN_API_TOKEN", ""),
}
// merge env configuration on top of the default/file configuration
if err := mergo.Merge(p, e, mergo.WithOverride); err != nil {
return nil, err
}
return &externalProvider{zone: zone, setter: p}, nil
func cloudflareProvider() libdns.RecordSetter {
zap.L().Warn("Builtin cloudflare DNS plugin is deprecated and will be removed in a future release.")
return &cloudflare.Provider{}
}
func configureGoogleCloudDNSProvider(zone string, values map[string]string) (Provider, error) {
p := &googleclouddns.Provider{}
if err := mapping.CopyViaJson(values, p); err != nil {
return nil, err
}
e := &googleclouddns.Provider{
Project: config.GetString("IONSCALE_DNS_GOOGLECLOUDDNS_PROJECT", ""),
ServiceAccountJSON: config.GetString("IONSCALE_DNS_GOOGLECLOUDDNS_SERVICE_ACCOUNT_JSON", ""),
}
// merge env configuration on top of the default/file configuration
if err := mergo.Merge(p, e, mergo.WithOverride); err != nil {
return nil, err
}
return &externalProvider{zone: zone, setter: p}, nil
func digitalOceanProvider() libdns.RecordSetter {
zap.L().Warn("Builtin digitalocean DNS plugin is deprecated and will be removed in a future release.")
return &digitalocean.Provider{}
}
func configureRoute53Provider(zone string, values map[string]string) (Provider, error) {
p := &route53.Provider{}
if err := mapping.CopyViaJson(values, p); err != nil {
return nil, err
}
func googleCloudDNSProvider() libdns.RecordSetter {
zap.L().Warn("Builtin googleclouddns DNS plugin is deprecated and will be removed in a future release.")
return &googleclouddns.Provider{}
}
e := &route53.Provider{
MaxRetries: 0,
MaxWaitDur: 0,
WaitForPropagation: false,
Region: config.GetString("IONSCALE_DNS_ROUTE53_REGION", ""),
AWSProfile: config.GetString("IONSCALE_DNS_ROUTE53_AWS_PROFILE", ""),
AccessKeyId: config.GetString("IONSCALE_DNS_ROUTE53_ACCESS_KEY_ID", ""),
SecretAccessKey: config.GetString("IONSCALE_DNS_ROUTE53_SECRET_ACCESS_KEY", ""),
Token: config.GetString("IONSCALE_DNS_ROUTE53_TOKEN", ""),
}
// merge env configuration on top of the default/file configuration
if err := mergo.Merge(p, e, mergo.WithOverride); err != nil {
return nil, err
}
return &externalProvider{zone: zone, setter: p}, nil
func route53Provider() libdns.RecordSetter {
zap.L().Warn("Builtin route53 DNS plugin is deprecated and will be removed in a future release.")
return &route53.Provider{}
}
type externalProvider struct {
@@ -154,11 +93,18 @@ type externalProvider struct {
}
func (p *externalProvider) SetRecord(ctx context.Context, recordType, recordName, value string) error {
_, err := p.setter.SetRecords(ctx, fmt.Sprintf("%s.", p.zone), []libdns.Record{{
_, err := p.setter.SetRecords(ctx, p.zone, []libdns.Record{{
Type: recordType,
Name: strings.TrimSuffix(recordName, p.zone),
Name: libdns.RelativeName(recordName, p.zone),
Value: value,
TTL: 1 * time.Minute,
}})
return err
}
func fqdn(v string) string {
if strings.HasSuffix(v, ".") {
return v
}
return fmt.Sprintf("%s.", v)
}
+21
View File
@@ -5,8 +5,15 @@ import (
"errors"
"github.com/jsiebens/ionscale/internal/util"
"gorm.io/gorm"
"time"
)
type AccountRepository interface {
GetAccount(ctx context.Context, accountID uint64) (*Account, error)
GetOrCreateAccount(ctx context.Context, externalID, loginName string) (*Account, bool, error)
SetAccountLastAuthenticated(ctx context.Context, accountID uint64) error
}
type Account struct {
ID uint64 `gorm:"primary_key"`
ExternalID string
@@ -43,3 +50,17 @@ func (r *repository) GetAccount(ctx context.Context, id uint64) (*Account, error
return &account, nil
}
func (r *repository) SetAccountLastAuthenticated(ctx context.Context, accountID uint64) error {
now := time.Now().UTC()
tx := r.withContext(ctx).
Model(Account{}).
Where("id = ?", accountID).
Updates(map[string]interface{}{"last_authenticated": &now})
if tx.Error != nil {
return tx.Error
}
return nil
}
+128 -252
View File
@@ -5,9 +5,12 @@ import (
"encoding/json"
"fmt"
"github.com/hashicorp/go-multierror"
"github.com/jsiebens/ionscale/pkg/client/ionscale"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"net/netip"
"reflect"
"slices"
"sort"
"strconv"
"strings"
@@ -15,48 +18,31 @@ import (
)
const (
AutoGroupSelf = "autogroup:self"
AutoGroupMembers = "autogroup:members"
AutoGroupInternet = "autogroup:internet"
AutoGroupSelf = "autogroup:self"
AutoGroupMember = "autogroup:member"
AutoGroupMembers = "autogroup:members"
AutoGroupTagged = "autogroup:tagged"
AutoGroupInternet = "autogroup:internet"
AutoGroupDangerAll = "autogroup:danger-all"
)
type AutoApprovers struct {
Routes map[string][]string `json:"routes"`
ExitNode []string `json:"exitNode"`
Routes map[string][]string `json:"routes,omitempty"`
ExitNode []string `json:"exitNode,omitempty"`
}
type ACLPolicy struct {
Groups map[string][]string `json:"groups,omitempty"`
Hosts map[string]string `json:"hosts,omitempty"`
ACLs []ACL `json:"acls"`
TagOwners map[string][]string `json:"tagowners"`
AutoApprovers *AutoApprovers `json:"autoApprovers"`
SSHRules []SSHRule `json:"ssh"`
ionscale.ACLPolicy
}
type ACL struct {
Action string `json:"action"`
Src []string `json:"src"`
Dst []string `json:"dst"`
}
type SSHRule struct {
Action string `json:"action"`
Src []string `json:"src"`
Dst []string `json:"dst"`
Users []string `json:"users"`
}
func DefaultPolicy() ACLPolicy {
return ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"*:*"},
},
},
func (a *ACLPolicy) Equal(x *ACLPolicy) bool {
if a == nil && x == nil {
return true
}
if (a == nil) != (x == nil) {
return false
}
return reflect.DeepEqual(a, x)
}
func (a ACLPolicy) FindAutoApprovedIPs(routableIPs []netip.Prefix, tags []string, u *User) []netip.Prefix {
@@ -99,7 +85,7 @@ func (a ACLPolicy) FindAutoApprovedIPs(routableIPs []netip.Prefix, tags []string
return false
}
autoApprovedIPs := []netip.Prefix{}
var autoApprovedIPs []netip.Prefix
for route, autoApprovers := range a.AutoApprovers.Routes {
candidate, err := netip.ParsePrefix(route)
if err != nil {
@@ -111,7 +97,7 @@ func (a ACLPolicy) FindAutoApprovedIPs(routableIPs []netip.Prefix, tags []string
}
}
result := []netip.Prefix{}
var result []netip.Prefix
for _, c := range routableIPs {
if c.Bits() == 0 && matches(a.AutoApprovers.ExitNode) {
result = append(result, c)
@@ -124,15 +110,6 @@ func (a ACLPolicy) FindAutoApprovedIPs(routableIPs []netip.Prefix, tags []string
return result
}
func (a ACLPolicy) IsTagOwner(tags []string, p *User) bool {
for _, t := range tags {
if a.isTagOwner(t, p) {
return true
}
}
return false
}
func (a ACLPolicy) CheckTagOwners(tags []string, p *User) error {
var result *multierror.Error
for _, t := range tags {
@@ -148,231 +125,75 @@ func (a ACLPolicy) isTagOwner(tag string, p *User) bool {
return true
}
if tagOwners, ok := a.TagOwners[tag]; ok {
return a.validateTagOwners(tagOwners, p)
for _, alias := range tagOwners {
if strings.HasPrefix(alias, "group:") {
if group, ok := a.Groups[alias]; ok {
return slices.Contains(group, p.Name)
}
} else {
if alias == p.Name {
return true
}
}
}
}
return false
}
func (a ACLPolicy) validateTagOwners(tagOwners []string, p *User) bool {
for _, alias := range tagOwners {
if strings.HasPrefix(alias, "group:") {
if group, ok := a.Groups[alias]; ok {
for _, groupMember := range group {
if groupMember == p.Name {
func (a ACLPolicy) NodeCapabilities(m *Machine) []tailcfg.NodeCapability {
var result = &StringSet{}
matches := func(targets []string) bool {
for _, alias := range targets {
if alias == "*" {
return true
}
if strings.Contains(alias, "@") && !m.HasTags() && m.HasUser(alias) {
return true
}
if strings.HasPrefix(alias, "tag:") && m.HasTag(alias) {
return true
}
if strings.HasPrefix(alias, "group:") && !m.HasTags() {
for _, u := range a.Groups[alias] {
if m.HasUser(u) {
return true
}
}
}
} else {
if alias == p.Name {
if (alias == AutoGroupMember || alias == AutoGroupMembers) && !m.HasTags() {
return true
}
}
return false
}
return false
for _, nodeAddr := range a.NodeAttrs {
if matches(nodeAddr.Target) {
result.Add(nodeAddr.Attr...)
}
}
items := result.Items()
caps := make([]tailcfg.NodeCapability, len(items))
for i, c := range items {
caps[i] = tailcfg.NodeCapability(c)
}
return caps
}
func (a ACLPolicy) IsValidPeer(src *Machine, dest *Machine) bool {
if !src.HasTags() && !dest.HasTags() && dest.HasUser(src.User.Name) {
return true
}
for _, acl := range a.ACLs {
selfDestPorts, allDestPorts := a.expandMachineToDstPorts(dest, acl.Dst)
if len(selfDestPorts) != 0 {
for _, alias := range acl.Src {
if len(a.expandMachineAlias(src, alias, true, &dest.User)) != 0 {
return true
}
}
}
if len(allDestPorts) != 0 {
for _, alias := range acl.Src {
if len(a.expandMachineAlias(src, alias, true, nil)) != 0 {
return true
}
}
}
}
return false
}
func (a ACLPolicy) BuildFilterRules(srcs []Machine, dst *Machine) []tailcfg.FilterRule {
var rules []tailcfg.FilterRule
transform := func(src []string, destPorts []tailcfg.NetPortRange, u *User) tailcfg.FilterRule {
var allSrcIPsSet = &StringSet{}
for _, alias := range src {
for _, src := range srcs {
srcIPs := a.expandMachineAlias(&src, alias, true, u)
allSrcIPsSet.Add(srcIPs...)
}
}
allSrcIPs := allSrcIPsSet.Items()
if len(allSrcIPs) == 0 {
allSrcIPs = nil
}
return tailcfg.FilterRule{
SrcIPs: allSrcIPs,
DstPorts: destPorts,
}
}
for _, acl := range a.ACLs {
selfDestPorts, allDestPorts := a.expandMachineToDstPorts(dst, acl.Dst)
if len(selfDestPorts) != 0 {
rules = append(rules, transform(acl.Src, selfDestPorts, &dst.User))
}
if len(allDestPorts) != 0 {
rules = append(rules, transform(acl.Src, allDestPorts, nil))
}
}
if len(rules) == 0 {
return []tailcfg.FilterRule{{}}
}
return rules
}
func (a ACLPolicy) expandMachineToDstPorts(m *Machine, ports []string) ([]tailcfg.NetPortRange, []tailcfg.NetPortRange) {
selfDestRanges := []tailcfg.NetPortRange{}
otherDestRanges := []tailcfg.NetPortRange{}
for _, d := range ports {
self, ranges := a.expandMachineDestToNetPortRanges(m, d)
if self {
selfDestRanges = append(selfDestRanges, ranges...)
} else {
otherDestRanges = append(otherDestRanges, ranges...)
}
}
return selfDestRanges, otherDestRanges
}
func (a ACLPolicy) expandMachineDestToNetPortRanges(m *Machine, dest string) (bool, []tailcfg.NetPortRange) {
tokens := strings.Split(dest, ":")
if len(tokens) < 2 || len(tokens) > 3 {
return false, nil
}
var alias string
if len(tokens) == 2 {
alias = tokens[0]
} else {
alias = fmt.Sprintf("%s:%s", tokens[0], tokens[1])
}
ports, err := a.expandValuePortToPortRange(tokens[len(tokens)-1])
if err != nil {
return false, nil
}
ips := a.expandMachineAlias(m, alias, false, nil)
if len(ips) == 0 {
return false, nil
}
dests := []tailcfg.NetPortRange{}
for _, d := range ips {
for _, p := range ports {
pr := tailcfg.NetPortRange{
IP: d,
Ports: p,
}
dests = append(dests, pr)
}
}
return alias == AutoGroupSelf, dests
}
func (a ACLPolicy) expandMachineAlias(m *Machine, alias string, src bool, u *User) []string {
if u != nil && m.HasTags() {
return []string{}
}
if u != nil && !m.HasUser(u.Name) {
return []string{}
}
if alias == "*" && u != nil {
return m.IPs()
}
if alias == "*" {
return []string{"*"}
}
if alias == AutoGroupMembers || alias == AutoGroupSelf {
if !m.HasTags() {
return m.IPs()
} else {
return []string{}
}
}
if alias == AutoGroupInternet && m.IsExitNode() {
return autogroupInternetRanges()
}
if strings.Contains(alias, "@") && !m.HasTags() && m.HasUser(alias) {
return m.IPs()
}
if strings.HasPrefix(alias, "group:") && !m.HasTags() {
users, ok := a.Groups[alias]
if !ok {
return []string{}
}
for _, u := range users {
if m.HasUser(u) {
return m.IPs()
}
}
return []string{}
}
if strings.HasPrefix(alias, "tag:") && m.HasTag(alias) {
return m.IPs()
}
if h, ok := a.Hosts[alias]; ok {
alias = h
}
if src {
ip, err := netip.ParseAddr(alias)
if err == nil && m.HasIP(ip) {
return []string{ip.String()}
}
} else {
ip, err := netip.ParseAddr(alias)
if err == nil && m.IsAllowedIP(ip) {
return []string{ip.String()}
}
prefix, err := netip.ParsePrefix(alias)
if err == nil && m.IsAllowedIPPrefix(prefix) {
return []string{prefix.String()}
}
}
return []string{}
}
func (a ACLPolicy) expandValuePortToPortRange(s string) ([]tailcfg.PortRange, error) {
func (a ACLPolicy) parsePortRanges(s string) ([]tailcfg.PortRange, error) {
if s == "*" {
return []tailcfg.PortRange{{First: 0, Last: 65535}}, nil
return []tailcfg.PortRange{tailcfg.PortRangeAny}, nil
}
ports := []tailcfg.PortRange{}
var ports []tailcfg.PortRange
for _, p := range strings.Split(s, ",") {
rang := strings.Split(p, "-")
if len(rang) == 1 {
@@ -451,6 +272,57 @@ func (ACLPolicy) GormDBDataType(db *gorm.DB, field *schema.Field) string {
return ""
}
const (
protocolICMP = 1 // Internet Control Message
protocolIGMP = 2 // Internet Group Management
protocolIPv4 = 4 // IPv4 encapsulation
protocolTCP = 6 // Transmission Control
protocolEGP = 8 // Exterior Gateway Protocol
protocolIGP = 9 // any private interior gateway (used by Cisco for their IGRP)
protocolUDP = 17 // User Datagram
protocolGRE = 47 // Generic Routing Encapsulation
protocolESP = 50 // Encap Security Payload
protocolAH = 51 // Authentication Header
protocolIPv6ICMP = 58 // ICMP for IPv6
protocolSCTP = 132 // Stream Control Transmission Protocol
)
func parseProtocol(protocol string) []int {
switch protocol {
case "":
return nil
case "igmp":
return []int{protocolIGMP}
case "ipv4", "ip-in-ip":
return []int{protocolIPv4}
case "tcp":
return []int{protocolTCP}
case "egp":
return []int{protocolEGP}
case "igp":
return []int{protocolIGP}
case "udp":
return []int{protocolUDP}
case "gre":
return []int{protocolGRE}
case "esp":
return []int{protocolESP}
case "ah":
return []int{protocolAH}
case "sctp":
return []int{protocolSCTP}
case "icmp":
return []int{protocolICMP, protocolIPv6ICMP}
default:
n, err := strconv.Atoi(protocol)
if err != nil {
return nil
}
return []int{n}
}
}
type StringSet struct {
items map[string]bool
}
@@ -476,6 +348,10 @@ func (s *StringSet) Items() []string {
return items
}
func (s *StringSet) Empty() bool {
return len(s.items) == 0
}
func autogroupInternetRanges() []string {
return []string{
"0.0.0.0/5",
+397
View File
@@ -0,0 +1,397 @@
package domain
import (
"fmt"
"github.com/jsiebens/ionscale/pkg/client/ionscale"
"net/netip"
"strings"
"tailscale.com/tailcfg"
)
func (a ACLPolicy) IsValidPeer(src *Machine, dest *Machine) bool {
if !src.HasTags() && !dest.HasTags() && dest.HasUser(src.User.Name) {
return true
}
for _, acl := range a.ACLs {
selfDestPorts, allDestPorts := a.translateDestinationAliasesToMachineNetPortRanges(acl.Destination, dest)
if len(selfDestPorts) != 0 {
for _, alias := range acl.Source {
if len(a.translateSourceAliasToMachineIPs(alias, src, &dest.User)) != 0 {
return true
}
}
}
if len(allDestPorts) != 0 {
for _, alias := range acl.Source {
if len(a.translateSourceAliasToMachineIPs(alias, src, nil)) != 0 {
return true
}
}
}
}
for _, grant := range a.Grants {
selfIps, otherIps := a.translateDestinationAliasesToMachineIPs(grant.Destination, dest)
if len(selfIps) != 0 {
for _, alias := range grant.Source {
if len(a.translateSourceAliasToMachineIPs(alias, src, &dest.User)) != 0 {
return true
}
}
}
if len(otherIps) != 0 {
for _, alias := range grant.Source {
if len(a.translateSourceAliasToMachineIPs(alias, src, nil)) != 0 {
return true
}
}
}
}
for _, ssh := range a.SSH {
selfIps, otherIps := a.translateDestinationAliasesToMachineIPs(ssh.Recorder, dest)
if len(selfIps) != 0 {
for _, alias := range ssh.Destination {
if len(a.translateSourceAliasToMachineIPs(alias, src, &dest.User)) != 0 {
return true
}
}
}
if len(otherIps) != 0 {
for _, alias := range ssh.Destination {
if len(a.translateSourceAliasToMachineIPs(alias, src, nil)) != 0 {
return true
}
}
}
}
return false
}
func (a ACLPolicy) BuildFilterRules(peers []Machine, dst *Machine) []tailcfg.FilterRule {
var rules = make([]tailcfg.FilterRule, 0)
matchSourceAndAppendRule := func(rules []tailcfg.FilterRule, aliases []string, preparedRules []tailcfg.FilterRule, u *User) []tailcfg.FilterRule {
if len(preparedRules) == 0 {
return rules
}
var allSrcIPsSet = &StringSet{}
for _, alias := range aliases {
for _, peer := range peers {
allSrcIPsSet.Add(a.translateSourceAliasToMachineIPs(alias, &peer, u)...)
}
}
if allSrcIPsSet.Empty() {
return rules
}
allSrcIPs := allSrcIPsSet.Items()
if len(allSrcIPs) == 0 {
return rules
}
for _, pr := range preparedRules {
rules = append(rules, tailcfg.FilterRule{
SrcIPs: allSrcIPs,
DstPorts: pr.DstPorts,
IPProto: pr.IPProto,
CapGrant: pr.CapGrant,
})
}
return rules
}
for _, acl := range a.ACLs {
self, other := a.prepareFilterRulesFromACL(dst, acl)
rules = matchSourceAndAppendRule(rules, acl.Source, self, &dst.User)
rules = matchSourceAndAppendRule(rules, acl.Source, other, nil)
}
for _, acl := range a.Grants {
self, other := a.prepareFilterRulesFromGrant(dst, acl)
rules = matchSourceAndAppendRule(rules, acl.Source, self, &dst.User)
rules = matchSourceAndAppendRule(rules, acl.Source, other, nil)
}
for _, acl := range a.SSH {
ssh := a.prepareFilterRulesFromSSH(dst, acl)
rules = matchSourceAndAppendRule(rules, acl.Destination, ssh, nil)
}
return rules
}
func normalizeSSHRecordersToDestinationAliases(recorders []string) []string {
recorderAliases := make([]string, 0)
for _, alias := range recorders {
if strings.HasPrefix(alias, "tag:") {
recorderAliases = append(recorderAliases, fmt.Sprintf("%s:80", alias))
}
}
return recorderAliases
}
func (a ACLPolicy) prepareFilterRulesFromSSH(candidate *Machine, entry ionscale.ACLSSH) []tailcfg.FilterRule {
_, otherDstPorts := a.translateDestinationAliasesToMachineNetPortRanges(normalizeSSHRecordersToDestinationAliases(entry.Recorder), candidate)
var otherFilterRules []tailcfg.FilterRule
if len(otherDstPorts) != 0 {
otherFilterRules = append(otherFilterRules, tailcfg.FilterRule{IPProto: []int{protocolTCP}, DstPorts: otherDstPorts})
}
return otherFilterRules
}
func (a ACLPolicy) prepareFilterRulesFromACL(candidate *Machine, acl ionscale.ACLEntry) ([]tailcfg.FilterRule, []tailcfg.FilterRule) {
proto := parseProtocol(acl.Protocol)
selfDstPorts, otherDstPorts := a.translateDestinationAliasesToMachineNetPortRanges(acl.Destination, candidate)
var selfFilterRules []tailcfg.FilterRule
var otherFilterRules []tailcfg.FilterRule
if len(selfDstPorts) != 0 {
selfFilterRules = append(selfFilterRules, tailcfg.FilterRule{IPProto: proto, DstPorts: selfDstPorts})
}
if len(otherDstPorts) != 0 {
otherFilterRules = append(otherFilterRules, tailcfg.FilterRule{IPProto: proto, DstPorts: otherDstPorts})
}
return selfFilterRules, otherFilterRules
}
func (a ACLPolicy) prepareFilterRulesFromGrant(candidate *Machine, grant ionscale.ACLGrant) ([]tailcfg.FilterRule, []tailcfg.FilterRule) {
selfIPs, otherIPs := a.translateDestinationAliasesToMachineIPs(grant.Destination, candidate)
var selfFilterRules []tailcfg.FilterRule
var otherFilterRules []tailcfg.FilterRule
for _, ip := range grant.IP {
if len(selfIPs) != 0 {
ranges := make([]tailcfg.NetPortRange, len(selfIPs))
for i, s := range selfIPs {
ranges[i] = tailcfg.NetPortRange{IP: s, Ports: ip.Ports}
}
rule := tailcfg.FilterRule{DstPorts: ranges}
if ip.Proto != 0 {
rule.IPProto = []int{ip.Proto}
}
selfFilterRules = append(selfFilterRules, rule)
}
if len(otherIPs) != 0 {
ranges := make([]tailcfg.NetPortRange, len(otherIPs))
for i, s := range otherIPs {
ranges[i] = tailcfg.NetPortRange{IP: s, Ports: ip.Ports}
}
rule := tailcfg.FilterRule{DstPorts: ranges}
if ip.Proto != 0 {
rule.IPProto = []int{ip.Proto}
}
otherFilterRules = append(otherFilterRules, rule)
}
}
if len(grant.App) != 0 {
selfPrefixes, otherPrefixes := appGrantDstIpsToPrefixes(candidate, selfIPs, otherIPs)
if len(selfPrefixes) != 0 {
rule := tailcfg.FilterRule{CapGrant: []tailcfg.CapGrant{{Dsts: selfPrefixes, CapMap: grant.App}}}
selfFilterRules = append(selfFilterRules, rule)
}
if len(otherPrefixes) != 0 {
rule := tailcfg.FilterRule{CapGrant: []tailcfg.CapGrant{{Dsts: otherPrefixes, CapMap: grant.App}}}
otherFilterRules = append(otherFilterRules, rule)
}
}
return selfFilterRules, otherFilterRules
}
func appGrantDstIpsToPrefixes(m *Machine, self []string, other []string) ([]netip.Prefix, []netip.Prefix) {
translate := func(ips []string) []netip.Prefix {
var prefixes []netip.Prefix
for _, ip := range ips {
if ip == "*" {
prefixes = append(prefixes, netip.PrefixFrom(*m.IPv4.Addr, 32))
prefixes = append(prefixes, netip.PrefixFrom(*m.IPv6.Addr, 128))
} else {
addr, err := netip.ParseAddr(ip)
if err == nil && m.HasIP(addr) {
if addr.Is4() {
prefixes = append(prefixes, netip.PrefixFrom(addr, 32))
} else {
prefixes = append(prefixes, netip.PrefixFrom(addr, 128))
}
}
}
}
return prefixes
}
return translate(self), translate(other)
}
func (a ACLPolicy) translateDestinationAliasesToMachineIPs(aliases []string, m *Machine) ([]string, []string) {
var self = &StringSet{}
var other = &StringSet{}
for _, alias := range aliases {
ips := a.translateDestinationAliasToMachineIPs(alias, m)
if alias == AutoGroupSelf {
self.Add(ips...)
} else {
other.Add(ips...)
}
}
return self.Items(), other.Items()
}
func (a ACLPolicy) translateDestinationAliasesToMachineNetPortRanges(aliases []string, m *Machine) ([]tailcfg.NetPortRange, []tailcfg.NetPortRange) {
var self []tailcfg.NetPortRange
var other []tailcfg.NetPortRange
for _, alias := range aliases {
ranges := a.translationDestinationAliasToMachineNetPortRanges(alias, m)
if strings.HasPrefix(alias, AutoGroupSelf) {
self = append(self, ranges...)
} else {
other = append(other, ranges...)
}
}
return self, other
}
func (a ACLPolicy) translationDestinationAliasToMachineNetPortRanges(alias string, m *Machine) []tailcfg.NetPortRange {
lastInd := strings.LastIndex(alias, ":")
if lastInd == -1 {
return nil
}
ports := alias[lastInd+1:]
alias = alias[:lastInd]
portRanges, err := a.parsePortRanges(ports)
if err != nil {
return nil
}
ips := a.translateDestinationAliasToMachineIPs(alias, m)
if len(ips) == 0 {
return nil
}
var netPortRanges []tailcfg.NetPortRange
for _, d := range ips {
for _, p := range portRanges {
pr := tailcfg.NetPortRange{
IP: d,
Ports: p,
}
netPortRanges = append(netPortRanges, pr)
}
}
return netPortRanges
}
func (a ACLPolicy) translateDestinationAliasToMachineIPs(alias string, m *Machine) []string {
f := func(alias string, m *Machine) []string {
ip, err := netip.ParseAddr(alias)
if err == nil && m.IsAllowedIP(ip) {
return []string{ip.String()}
}
prefix, err := netip.ParsePrefix(alias)
if err == nil && m.IsAllowedIPPrefix(prefix) {
return []string{prefix.String()}
}
return make([]string, 0)
}
if alias == "*" {
return []string{"*"}
}
return a.translateAliasToMachineIPs(alias, m, f)
}
func (a ACLPolicy) translateSourceAliasToMachineIPs(alias string, m *Machine, u *User) []string {
f := func(alias string, m *Machine) []string {
ip, err := netip.ParseAddr(alias)
if err == nil && m.HasIP(ip) {
return []string{ip.String()}
}
return make([]string, 0)
}
if u != nil && m.HasTags() {
return []string{}
}
if u != nil && !m.HasUser(u.Name) {
return []string{}
}
if alias == "*" {
return append(m.IPs(), m.AllowedPrefixes()...)
}
if alias == AutoGroupDangerAll {
return []string{"0.0.0.0/0", "::/0"}
}
return a.translateAliasToMachineIPs(alias, m, f)
}
func (a ACLPolicy) translateAliasToMachineIPs(alias string, m *Machine, f func(string, *Machine) []string) []string {
if alias == AutoGroupMember || alias == AutoGroupMembers || alias == AutoGroupSelf {
if !m.HasTags() {
return m.IPs()
} else {
return []string{}
}
}
if alias == AutoGroupTagged {
if m.HasTags() {
return m.IPs()
} else {
return []string{}
}
}
if alias == AutoGroupInternet && m.IsExitNode() {
return autogroupInternetRanges()
}
if strings.Contains(alias, "@") && !m.HasTags() && m.HasUser(alias) {
return m.IPs()
}
if strings.HasPrefix(alias, "group:") && !m.HasTags() && a.isGroupMember(alias, m) {
return m.IPs()
}
if strings.HasPrefix(alias, "tag:") && m.HasTag(alias) {
return m.IPs()
}
if h, ok := a.Hosts[alias]; ok {
alias = h
}
return f(alias, m)
}
+42 -8
View File
@@ -1,6 +1,8 @@
package domain
import (
"github.com/jsiebens/ionscale/pkg/client/ionscale"
"net/netip"
"strings"
"tailscale.com/tailcfg"
)
@@ -28,7 +30,21 @@ func (a ACLPolicy) BuildSSHPolicy(srcs []Machine, dst *Machine) *tailcfg.SSHPoli
return result
}
for _, rule := range a.SSHRules {
expandRecorderAliases := func(aliases []string) []netip.AddrPort {
result := make([]netip.AddrPort, 0)
for _, alias := range aliases {
for _, src := range append(srcs, *dst) {
if src.HasTag(alias) {
result = append(result, netip.AddrPortFrom(*src.IPv4.Addr, 80))
}
}
}
return result
}
for _, rule := range a.SSH {
if rule.Action != "accept" && rule.Action != "check" {
continue
}
@@ -41,14 +57,25 @@ func (a ACLPolicy) BuildSSHPolicy(srcs []Machine, dst *Machine) *tailcfg.SSHPoli
if rule.Action == "check" {
action = &tailcfg.SSHAction{
HoldAndDelegate: "https://unused/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID",
HoldAndDelegate: "https://unused/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID/" + safeCheckPeriod(rule.CheckPeriod),
}
}
if len(rule.Recorder) != 0 {
action.Recorders = expandRecorderAliases(rule.Recorder)
action.Message = "# This session is being recorded.\n"
if rule.EnforceRecorder {
action.OnRecordingFailure = &tailcfg.SSHRecorderFailureAction{
RejectSessionWithMessage: "# Session rejected: failed to start session recording.",
TerminateSessionWithMessage: "# Session terminated: failed to record session.",
}
}
}
selfUsers, otherUsers := a.expandSSHDstToSSHUsers(dst, rule)
if len(selfUsers) != 0 {
principals := expandSrcAliases(rule.Src, rule.Action, &dst.User)
principals := expandSrcAliases(rule.Source, rule.Action, &dst.User)
if len(principals) != 0 {
rules = append(rules, &tailcfg.SSHRule{
Principals: principals,
@@ -59,7 +86,7 @@ func (a ACLPolicy) BuildSSHPolicy(srcs []Machine, dst *Machine) *tailcfg.SSHPoli
}
if len(otherUsers) != 0 {
principals := expandSrcAliases(rule.Src, rule.Action, nil)
principals := expandSrcAliases(rule.Source, rule.Action, nil)
if len(principals) != 0 {
rules = append(rules, &tailcfg.SSHRule{
Principals: principals,
@@ -79,7 +106,7 @@ func (a ACLPolicy) expandSSHSrcAlias(m *Machine, alias string, dstUser *User) []
return []string{}
}
if alias == AutoGroupMembers {
if alias == AutoGroupMember || alias == AutoGroupMembers {
return m.IPs()
}
@@ -94,7 +121,7 @@ func (a ACLPolicy) expandSSHSrcAlias(m *Machine, alias string, dstUser *User) []
return []string{}
}
if alias == AutoGroupMembers && !m.HasTags() {
if (alias == AutoGroupMember || alias == AutoGroupMembers) && !m.HasTags() {
return m.IPs()
}
@@ -113,13 +140,13 @@ func (a ACLPolicy) expandSSHSrcAlias(m *Machine, alias string, dstUser *User) []
return []string{}
}
func (a ACLPolicy) expandSSHDstToSSHUsers(m *Machine, rule SSHRule) (map[string]string, map[string]string) {
func (a ACLPolicy) expandSSHDstToSSHUsers(m *Machine, rule ionscale.ACLSSH) (map[string]string, map[string]string) {
users := buildSSHUsers(rule.Users)
var selfUsers map[string]string
var otherUsers map[string]string
for _, d := range rule.Dst {
for _, d := range rule.Destination {
if strings.HasPrefix(d, "tag:") && m.HasTag(d) {
otherUsers = users
}
@@ -151,3 +178,10 @@ func buildSSHUsers(users []string) map[string]string {
return m
}
func safeCheckPeriod(period string) string {
if period == "" {
return "always"
}
return period
}
+96 -83
View File
@@ -1,8 +1,7 @@
package domain
import (
"encoding/json"
"fmt"
"github.com/jsiebens/ionscale/pkg/client/ionscale"
"github.com/stretchr/testify/assert"
"tailscale.com/tailcfg"
"testing"
@@ -13,12 +12,14 @@ func TestACLPolicy_BuildSSHPolicy_(t *testing.T) {
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"autogroup:members"},
Dst: []string{"autogroup:self"},
Users: []string{"autogroup:nonroot"},
ionscale.ACLPolicy{
SSH: []ionscale.ACLSSH{
{
Action: "accept",
Source: []string{"autogroup:members"},
Destination: []string{"autogroup:self"},
Users: []string{"autogroup:nonroot"},
},
},
},
}
@@ -52,17 +53,19 @@ func TestACLPolicy_BuildSSHPolicy_WithGroup(t *testing.T) {
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
Groups: map[string][]string{
"group:sre": {
"john@example.com",
ionscale.ACLPolicy{
Groups: map[string][]string{
"group:sre": {
"john@example.com",
},
},
},
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"group:sre"},
Dst: []string{"tag:web"},
Users: []string{"autogroup:nonroot", "root"},
SSH: []ionscale.ACLSSH{
{
Action: "accept",
Source: []string{"group:sre"},
Destination: []string{"tag:web"},
Users: []string{"autogroup:nonroot", "root"},
},
},
},
}
@@ -96,12 +99,14 @@ func TestACLPolicy_BuildSSHPolicy_WithMatchingUsers(t *testing.T) {
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"john@example.com"},
Dst: []string{"john@example.com"},
Users: []string{"autogroup:nonroot", "root"},
ionscale.ACLPolicy{
SSH: []ionscale.ACLSSH{
{
Action: "accept",
Source: []string{"john@example.com"},
Destination: []string{"john@example.com"},
Users: []string{"autogroup:nonroot", "root"},
},
},
},
}
@@ -132,15 +137,17 @@ func TestACLPolicy_BuildSSHPolicy_WithMatchingUsersInGroup(t *testing.T) {
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
Groups: map[string][]string{
"group:sre": {"jane@example.com", "john@example.com"},
},
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"group:sre"},
Dst: []string{"john@example.com"},
Users: []string{"autogroup:nonroot", "root"},
ionscale.ACLPolicy{
Groups: map[string][]string{
"group:sre": {"jane@example.com", "john@example.com"},
},
SSH: []ionscale.ACLSSH{
{
Action: "accept",
Source: []string{"group:sre"},
Destination: []string{"john@example.com"},
Users: []string{"autogroup:nonroot", "root"},
},
},
},
}
@@ -171,12 +178,14 @@ func TestACLPolicy_BuildSSHPolicy_WithNoMatchingUsers(t *testing.T) {
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"jane@example.com"},
Dst: []string{"john@example.com"},
Users: []string{"autogroup:nonroot", "root"},
ionscale.ACLPolicy{
SSH: []ionscale.ACLSSH{
{
Action: "accept",
Source: []string{"jane@example.com"},
Destination: []string{"john@example.com"},
Users: []string{"autogroup:nonroot", "root"},
},
},
},
}
@@ -194,12 +203,14 @@ func TestACLPolicy_BuildSSHPolicy_WithTags(t *testing.T) {
p3 := createMachine("nick@example.com", "tag:web")
policy := ACLPolicy{
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"john@example.com", "tag:web"},
Dst: []string{"tag:web"},
Users: []string{"ubuntu"},
ionscale.ACLPolicy{
SSH: []ionscale.ACLSSH{
{
Action: "accept",
Source: []string{"john@example.com", "tag:web"},
Destination: []string{"tag:web"},
Users: []string{"ubuntu"},
},
},
},
}
@@ -230,12 +241,14 @@ func TestACLPolicy_BuildSSHPolicy_WithTagsInDstAndAutogroupMemberInSrc(t *testin
p3 := createMachine("nick@example.com", "tag:web")
policy := ACLPolicy{
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"autogroup:members"},
Dst: []string{"tag:web"},
Users: []string{"ubuntu"},
ionscale.ACLPolicy{
SSH: []ionscale.ACLSSH{
{
Action: "accept",
Source: []string{"autogroup:members"},
Destination: []string{"tag:web"},
Users: []string{"ubuntu"},
},
},
},
}
@@ -265,12 +278,14 @@ func TestACLPolicy_BuildSSHPolicy_WithUserInDstAndNonMatchingSrc(t *testing.T) {
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"jane@example.com"},
Dst: []string{"john@example.com"},
Users: []string{"autogroup:nonroot"},
ionscale.ACLPolicy{
SSH: []ionscale.ACLSSH{
{
Action: "accept",
Source: []string{"jane@example.com"},
Destination: []string{"john@example.com"},
Users: []string{"autogroup:nonroot"},
},
},
},
}
@@ -287,12 +302,14 @@ func TestACLPolicy_BuildSSHPolicy_WithUserInDstAndAutogroupMembersSrc(t *testing
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"autogroup:members"},
Dst: []string{"john@example.com"},
Users: []string{"autogroup:nonroot"},
ionscale.ACLPolicy{
SSH: []ionscale.ACLSSH{
{
Action: "accept",
Source: []string{"autogroup:members"},
Destination: []string{"john@example.com"},
Users: []string{"autogroup:nonroot"},
},
},
},
}
@@ -323,12 +340,14 @@ func TestACLPolicy_BuildSSHPolicy_WithAutogroupSelfAndTagSrc(t *testing.T) {
p2 := createMachine("jane@example.com", "tag:web")
policy := ACLPolicy{
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"tag:web"},
Dst: []string{"autogroup:self"},
Users: []string{"autogroup:nonroot"},
ionscale.ACLPolicy{
SSH: []ionscale.ACLSSH{
{
Action: "accept",
Source: []string{"tag:web"},
Destination: []string{"autogroup:self"},
Users: []string{"autogroup:nonroot"},
},
},
},
}
@@ -345,12 +364,14 @@ func TestACLPolicy_BuildSSHPolicy_WithTagsAndActionCheck(t *testing.T) {
p2 := createMachine("jane@example.com", "tag:web")
policy := ACLPolicy{
SSHRules: []SSHRule{
{
Action: "check",
Src: []string{"tag:web"},
Dst: []string{"tag:web"},
Users: []string{"autogroup:nonroot"},
ionscale.ACLPolicy{
SSH: []ionscale.ACLSSH{
{
Action: "check",
Source: []string{"tag:web"},
Destination: []string{"tag:web"},
Users: []string{"autogroup:nonroot"},
},
},
},
}
@@ -362,14 +383,6 @@ func TestACLPolicy_BuildSSHPolicy_WithTagsAndActionCheck(t *testing.T) {
assert.Nil(t, actualRules.Rules)
}
func printRules(rules []*tailcfg.SSHRule) {
indent, err := json.MarshalIndent(rules, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(indent))
}
func sshPrincipalsFromMachines(machines ...Machine) []*tailcfg.SSHPrincipal {
x := StringSet{}
for _, m := range machines {
+596 -125
View File
@@ -1,24 +1,180 @@
package domain
import (
"encoding/json"
"github.com/jsiebens/ionscale/internal/addr"
"github.com/jsiebens/ionscale/pkg/client/ionscale"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/netip"
"sort"
"tailscale.com/tailcfg"
"testing"
)
func TestACLPolicy_NodeAttributesWithWildcards(t *testing.T) {
p1 := createMachine("john@example.com")
policy := ACLPolicy{
ionscale.ACLPolicy{
NodeAttrs: []ionscale.ACLNodeAttrGrant{
{
Target: []string{"*"},
Attr: []string{
"attr1",
"attr2",
},
},
{
Target: []string{"*"},
Attr: []string{
"attr3",
},
},
},
},
}
actualAttrs := policy.NodeCapabilities(p1)
expectedAttrs := []tailcfg.NodeCapability{
tailcfg.NodeCapability("attr1"),
tailcfg.NodeCapability("attr2"),
tailcfg.NodeCapability("attr3"),
}
assert.Equal(t, expectedAttrs, actualAttrs)
}
func TestACLPolicy_NodeAttributesWithUserAndGroups(t *testing.T) {
p1 := createMachine("john@example.com")
policy := ACLPolicy{
ionscale.ACLPolicy{
Groups: map[string][]string{
"group:admins": []string{"john@example.com"},
},
NodeAttrs: []ionscale.ACLNodeAttrGrant{
{
Target: []string{"john@example.com"},
Attr: []string{
"attr1",
"attr2",
},
},
{
Target: []string{"jane@example.com", "group:analytics", "group:admins"},
Attr: []string{
"attr3",
},
},
},
},
}
actualAttrs := policy.NodeCapabilities(p1)
expectedAttrs := []tailcfg.NodeCapability{
tailcfg.NodeCapability("attr1"),
tailcfg.NodeCapability("attr2"),
tailcfg.NodeCapability("attr3"),
}
assert.Equal(t, expectedAttrs, actualAttrs)
}
func TestACLPolicy_NodeAttributesWithUserAndTags(t *testing.T) {
p1 := createMachine("john@example.com", "tag:web")
policy := ACLPolicy{
ionscale.ACLPolicy{
Groups: map[string][]string{
"group:admins": []string{"john@example.com"},
},
NodeAttrs: []ionscale.ACLNodeAttrGrant{
{
Target: []string{"john@example.com"},
Attr: []string{
"attr1",
"attr2",
},
},
{
Target: []string{"jane@example.com", "tag:web"},
Attr: []string{
"attr3",
},
},
},
},
}
actualAttrs := policy.NodeCapabilities(p1)
expectedAttrs := []tailcfg.NodeCapability{tailcfg.NodeCapability("attr3")}
assert.Equal(t, expectedAttrs, actualAttrs)
}
func TestACLPolicy_NodeAttributesWithAutoGroupMember(t *testing.T) {
p1 := createMachine("john@example.com")
policy := ACLPolicy{
ionscale.ACLPolicy{
NodeAttrs: []ionscale.ACLNodeAttrGrant{
{
Target: []string{"autogroup:member"},
Attr: []string{
"attr1",
"attr2",
},
},
{
Target: []string{"tag:web"},
Attr: []string{
"attr3",
},
},
},
},
}
actualAttrs := policy.NodeCapabilities(p1)
expectedAttrs := []tailcfg.NodeCapability{
tailcfg.NodeCapability("attr1"),
tailcfg.NodeCapability("attr2"),
}
assert.Equal(t, expectedAttrs, actualAttrs)
}
func TestACLPolicy_BuildFilterRulesEmptyACL(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2}, dst)
expectedRules := []tailcfg.FilterRule{}
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesWildcards(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"*:*"},
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"*"},
Destination: []string{"*:*"},
},
},
},
}
@@ -28,7 +184,7 @@ func TestACLPolicy_BuildFilterRulesWildcards(t *testing.T) {
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2}, dst)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: []string{"*"},
SrcIPs: expectedSourceIPs(p1, p2),
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
@@ -44,26 +200,84 @@ func TestACLPolicy_BuildFilterRulesWildcards(t *testing.T) {
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesProto(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"*"},
Destination: []string{"*:22"},
},
{
Action: "accept",
Source: []string{"*"},
Destination: []string{"*:*"},
Protocol: "igmp",
},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2}, dst)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: expectedSourceIPs(p1, p2),
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
Ports: tailcfg.PortRange{
First: 22,
Last: 22,
},
},
},
},
{
SrcIPs: expectedSourceIPs(p1, p2),
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
},
IPProto: []int{protocolIGMP},
},
}
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesWithGroups(t *testing.T) {
p1 := createMachine("jane@example.com")
p2 := createMachine("nick@example.com")
p3 := createMachine("joe@example.com")
policy := ACLPolicy{
Groups: map[string][]string{
"group:admin": []string{"jane@example.com"},
"group:audit": []string{"nick@example.com"},
},
ACLs: []ACL{
{
Action: "accept",
Src: []string{"group:admin"},
Dst: []string{"*:22"},
ionscale.ACLPolicy{
Groups: map[string][]string{
"group:admin": []string{"jane@example.com"},
"group:audit": []string{"nick@example.com"},
},
{
Action: "accept",
Src: []string{"group:audit"},
Dst: []string{"*:8000-8080"},
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"group:admin"},
Destination: []string{"*:22"},
},
{
Action: "accept",
Source: []string{"group:audit"},
Destination: []string{"*:8000-8080"},
},
},
},
}
@@ -113,11 +327,13 @@ func TestACLPolicy_BuildFilterRulesWithAutoGroupMembers(t *testing.T) {
p3 := createMachine("joe@example.com", "tag:web")
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"autogroup:members"},
Dst: []string{"*:22"},
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"autogroup:members"},
Destination: []string{"*:22"},
},
},
},
}
@@ -150,16 +366,108 @@ func TestACLPolicy_BuildFilterRulesWithAutoGroupMembers(t *testing.T) {
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesWithAutoGroupMember(t *testing.T) {
p1 := createMachine("jane@example.com")
p2 := createMachine("nick@example.com")
p3 := createMachine("joe@example.com", "tag:web")
policy := ACLPolicy{
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"autogroup:member"},
Destination: []string{"*:22"},
},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2, *p3}, dst)
expectedSrcIPs := []string{
p1.IPv4.String(), p1.IPv6.String(),
p2.IPv4.String(), p2.IPv6.String(),
}
sort.Strings(expectedSrcIPs)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: expectedSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
Ports: tailcfg.PortRange{
First: 22,
Last: 22,
},
},
},
},
}
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesWithAutoGroupTagged(t *testing.T) {
p1 := createMachine("jane@example.com")
p2 := createMachine("nick@example.com")
p3 := createMachine("joe@example.com", "tag:web")
policy := ACLPolicy{
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"autogroup:tagged"},
Destination: []string{"*:22"},
},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2, *p3}, dst)
expectedSrcIPs := []string{
p3.IPv4.String(), p3.IPv6.String(),
}
sort.Strings(expectedSrcIPs)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: expectedSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
Ports: tailcfg.PortRange{
First: 22,
Last: 22,
},
},
},
},
}
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesAutogroupSelf(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"autogroup:self:*"},
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"*"},
Destination: []string{"autogroup:self:*"},
},
},
},
}
@@ -200,11 +508,13 @@ func TestACLPolicy_BuildFilterRulesAutogroupSelfAndTags(t *testing.T) {
p2 := createMachine("john@example.com", "tag:web")
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"autogroup:self:*"},
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"*"},
Destination: []string{"autogroup:self:*"},
},
},
},
}
@@ -246,11 +556,13 @@ func TestACLPolicy_BuildFilterRulesAutogroupSelfAndOtherDestinations(t *testing.
p3 := createMachine("jane@example.com")
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"autogroup:self:22", "john@example.com:80"},
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"*"},
Destination: []string{"autogroup:self:22", "john@example.com:80"},
},
},
},
}
@@ -279,7 +591,7 @@ func TestACLPolicy_BuildFilterRulesAutogroupSelfAndOtherDestinations(t *testing.
},
},
{
SrcIPs: []string{"*"},
SrcIPs: expectedSourceIPs(p1, p2, p3),
DstPorts: []tailcfg.NetPortRange{
{
IP: dst.IPv4.String(),
@@ -302,54 +614,18 @@ func TestACLPolicy_BuildFilterRulesAutogroupSelfAndOtherDestinations(t *testing.
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesAutogroupMember(t *testing.T) {
p1 := createMachine("jane@example.com")
p2 := createMachine("jane@example.com", "tag:web")
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"autogroup:members"},
Dst: []string{"*:*"},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2}, dst)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: []string{
p1.IPv4.String(),
p1.IPv6.String(),
},
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
},
},
}
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesAutogroupInternet(t *testing.T) {
p1 := createMachine("nick@example.com")
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"nick@example.com"},
Dst: []string{"autogroup:internet:*"},
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"nick@example.com"},
Destination: []string{"autogroup:internet:*"},
},
},
},
}
@@ -384,13 +660,65 @@ func TestACLPolicy_BuildFilterRulesAutogroupInternet(t *testing.T) {
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesAutogroupDangerAll(t *testing.T) {
p1 := createMachine("nick@example.com")
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"autogroup:danger-all"},
Destination: []string{"*:*"},
},
},
},
}
dst := createMachine("john@example.com")
expectedDstPorts := []tailcfg.NetPortRange{}
for _, r := range autogroupInternetRanges() {
expectedDstPorts = append(expectedDstPorts, tailcfg.NetPortRange{
IP: r,
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
})
}
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2}, dst)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: []string{
"0.0.0.0/0", "::/0",
},
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
},
},
}
assert.Equal(t, expectedRules, actualRules)
}
func TestWithUser(t *testing.T) {
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"john@example.com:*"},
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"*"},
Destination: []string{"john@example.com:*"},
},
},
},
}
@@ -403,14 +731,16 @@ func TestWithUser(t *testing.T) {
func TestWithGroup(t *testing.T) {
policy := ACLPolicy{
Groups: map[string][]string{
"group:admin": {"john@example.com"},
},
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"group:admin:*"},
ionscale.ACLPolicy{
Groups: map[string][]string{
"group:admin": {"john@example.com"},
},
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"*"},
Destination: []string{"group:admin:*"},
},
},
},
}
@@ -422,11 +752,13 @@ func TestWithGroup(t *testing.T) {
func TestWithTags(t *testing.T) {
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"tag:web:*"},
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"*"},
Destination: []string{"tag:web:*"},
},
},
},
}
@@ -442,15 +774,17 @@ func TestWithHosts(t *testing.T) {
dst2 := createMachine("john@example.com")
policy := ACLPolicy{
Hosts: map[string]string{
"dst1": dst1.IPv4.String(),
},
ACLs: []ACL{
ionscale.ACLPolicy{
Hosts: map[string]string{
"dst1": dst1.IPv4.String(),
},
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"dst1:*"},
{
Action: "accept",
Source: []string{"*"},
Destination: []string{"dst1:*"},
},
},
},
}
@@ -478,14 +812,23 @@ func createMachine(user string, tags ...string) *Machine {
}
}
func expectedSourceIPs(m ...*Machine) []string {
x := &StringSet{}
for _, m := range m {
x = x.Add(m.IPv4.String(), m.IPv6.String())
}
return x.Items()
}
func TestACLPolicy_IsTagOwner(t *testing.T) {
policy := ACLPolicy{
Groups: map[string][]string{
"group:engineers": {"jane@example.com"},
},
TagOwners: map[string][]string{
"tag:web": {"john@example.com", "group:engineers"},
}}
ionscale.ACLPolicy{
Groups: map[string][]string{
"group:engineers": {"jane@example.com"},
},
TagOwners: map[string][]string{
"tag:web": {"john@example.com", "group:engineers"},
}}}
testCases := []struct {
name string
@@ -565,15 +908,17 @@ func TestACLPolicy_FindAutoApprovedIPs(t *testing.T) {
route3 := netip.MustParsePrefix("10.162.0.0/20")
policy := ACLPolicy{
Groups: map[string][]string{
"group:admins": {"jane@example.com"},
},
AutoApprovers: &AutoApprovers{
Routes: map[string][]string{
route1.String(): {"group:admins"},
route2.String(): {"john@example.com", "tag:router"},
ionscale.ACLPolicy{
Groups: map[string][]string{
"group:admins": {"jane@example.com"},
},
AutoApprovers: &ionscale.ACLAutoApprovers{
Routes: map[string][]string{
route1.String(): {"group:admins"},
route2.String(): {"john@example.com", "tag:router"},
},
ExitNode: []string{"nick@example.com"},
},
ExitNode: []string{"nick@example.com"},
},
}
@@ -628,7 +973,7 @@ func TestACLPolicy_FindAutoApprovedIPs(t *testing.T) {
name: "no match",
userName: "nick@example.com",
routableIPs: []netip.Prefix{route1, route2, route3},
expected: []netip.Prefix{},
expected: nil,
},
{
name: "exit",
@@ -640,7 +985,7 @@ func TestACLPolicy_FindAutoApprovedIPs(t *testing.T) {
name: "exit no match",
userName: "john@example.com",
routableIPs: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
expected: []netip.Prefix{},
expected: nil,
},
}
@@ -651,3 +996,129 @@ func TestACLPolicy_FindAutoApprovedIPs(t *testing.T) {
})
}
}
func TestACLPolicy_BuildFilterRulesWithAdvertisedRoutes(t *testing.T) {
route1 := netip.MustParsePrefix("fd7a:115c:a1e0:b1a:0:1:a3c:0/120")
p1 := createMachine("john@example.com", "tag:trusted")
policy := ACLPolicy{
ionscale.ACLPolicy{
ACLs: []ionscale.ACLEntry{
{
Action: "accept",
Source: []string{"tag:trusted"},
Destination: []string{"fd7a:115c:a1e0:b1a:0:1:a3c:0/120:*"},
},
},
},
}
dst := createMachine("john@example.com")
dst.AllowIPs = []netip.Prefix{route1}
actualRules := policy.BuildFilterRules([]Machine{*p1}, dst)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: p1.IPs(),
DstPorts: []tailcfg.NetPortRange{
{
IP: route1.String(),
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
},
},
}
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesWildcardGrants(t *testing.T) {
ranges, err := tailcfg.ParseProtoPortRanges([]string{"*"})
require.NoError(t, err)
p1 := createMachine("john@example.com")
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
ionscale.ACLPolicy{
Grants: []ionscale.ACLGrant{
{
Source: []string{"*"},
Destination: []string{"*"},
IP: ranges,
},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2}, dst)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: expectedSourceIPs(p1, p2),
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
},
},
}
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesWithAppGrants(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("jane@example.com")
dst := createMachine("john@example.com")
mycap := map[string]interface{}{
"channel": "alpha",
"ids": []string{"1", "2", "3"},
}
marshal, _ := json.Marshal(mycap)
policy := ACLPolicy{
ionscale.ACLPolicy{
Grants: []ionscale.ACLGrant{
{
Source: []string{"*"},
Destination: []string{"*"},
App: map[tailcfg.PeerCapability][]tailcfg.RawMessage{
tailcfg.PeerCapability("localtest.me/cap/test"): {tailcfg.RawMessage(marshal)},
},
},
},
},
}
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2}, dst)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: expectedSourceIPs(p1, p2),
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{
netip.PrefixFrom(*dst.IPv4.Addr, 32),
netip.PrefixFrom(*dst.IPv6.Addr, 128),
},
CapMap: map[tailcfg.PeerCapability][]tailcfg.RawMessage{
tailcfg.PeerCapability("localtest.me/cap/test"): {tailcfg.RawMessage(marshal)},
},
},
},
},
}
assert.Equal(t, expectedRules, actualRules)
}
+8 -1
View File
@@ -33,6 +33,13 @@ func CreateApiKey(tailnet *Tailnet, user *User, expiresAt *time.Time) (string, *
}
}
type ApiKeyRepository interface {
SaveApiKey(ctx context.Context, key *ApiKey) error
LoadApiKey(ctx context.Context, key string) (*ApiKey, error)
DeleteApiKeysByTailnet(ctx context.Context, tailnetID uint64) error
DeleteApiKeysByUser(ctx context.Context, userID uint64) error
}
type ApiKey struct {
ID uint64 `gorm:"primary_key"`
Key string
@@ -65,7 +72,7 @@ func (r *repository) LoadApiKey(ctx context.Context, key string) (*ApiKey, error
}
var m ApiKey
tx := r.withContext(ctx).Preload("User").Preload("Tailnet").First(&m, "key = ?", split[0])
tx := r.withContext(ctx).Preload("User").Preload("Tailnet").Take(&m, "key = ?", split[0])
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
+12 -1
View File
@@ -36,6 +36,17 @@ func CreateAuthKey(tailnet *Tailnet, user *User, ephemeral bool, preAuthorized b
}
}
type AuthKeyRepository interface {
GetAuthKey(ctx context.Context, id uint64) (*AuthKey, error)
SaveAuthKey(ctx context.Context, key *AuthKey) error
DeleteAuthKey(ctx context.Context, id uint64) (bool, error)
DeleteAuthKeysByTailnet(ctx context.Context, tailnetID uint64) error
DeleteAuthKeysByUser(ctx context.Context, userID uint64) error
ListAuthKeys(ctx context.Context, tailnetID uint64) ([]AuthKey, error)
ListAuthKeysByTailnetAndUser(ctx context.Context, tailnetID, userID uint64) ([]AuthKey, error)
LoadAuthKey(ctx context.Context, key string) (*AuthKey, error)
}
type AuthKey struct {
ID uint64 `gorm:"primary_key"`
Key string
@@ -136,7 +147,7 @@ func (r *repository) LoadAuthKey(ctx context.Context, key string) (*AuthKey, err
}
var m AuthKey
tx := r.withContext(ctx).Preload("User").Preload("Tailnet").First(&m, "key = ?", split[0])
tx := r.withContext(ctx).Preload("User").Preload("Tailnet").Take(&m, "key = ?", split[0])
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
+7 -1
View File
@@ -7,6 +7,12 @@ import (
"time"
)
type AuthenticationRequestRepository interface {
SaveAuthenticationRequest(ctx context.Context, session *AuthenticationRequest) error
GetAuthenticationRequest(ctx context.Context, key string) (*AuthenticationRequest, error)
DeleteAuthenticationRequest(ctx context.Context, key string) error
}
type AuthenticationRequest struct {
Key string `gorm:"primary_key"`
Token string
@@ -27,7 +33,7 @@ func (r *repository) SaveAuthenticationRequest(ctx context.Context, session *Aut
func (r *repository) GetAuthenticationRequest(ctx context.Context, key string) (*AuthenticationRequest, error) {
var m AuthenticationRequest
tx := r.withContext(ctx).First(&m, "key = ?", key)
tx := r.withContext(ctx).Take(&m, "key = ?", key)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
+87
View File
@@ -0,0 +1,87 @@
package domain
import (
"database/sql/driver"
"encoding/json"
"fmt"
"github.com/tailscale/hujson"
)
func NewHuJSON[T any](t *T) HuJSON[T] {
marshal, _ := json.Marshal(t)
return HuJSON[T]{
v: string(marshal),
t: t,
}
}
func ParseHuJson[T any](v string) (*HuJSON[T], error) {
ast, err := hujson.Parse([]byte(v))
if err != nil {
return nil, err
}
ast.Format()
formatted := string(ast.Pack())
ast.Standardize()
t := new(T)
if err := json.Unmarshal(ast.Pack(), t); err != nil {
return nil, err
}
return &HuJSON[T]{v: formatted, t: t}, nil
}
type HuJSON[T any] struct {
v string
t *T
}
func (h *HuJSON[T]) Get() *T {
return h.t
}
func (h *HuJSON[T]) String() string {
return h.v
}
func (i *HuJSON[T]) Equal(x *HuJSON[T]) bool {
if i == nil && x == nil {
return true
}
if (i == nil) != (x == nil) {
return false
}
return i.v == x.v
}
func (h HuJSON[T]) Value() (driver.Value, error) {
if len(h.v) == 0 {
return nil, nil
}
return h.v, nil
}
func (h *HuJSON[T]) Scan(destination interface{}) error {
var v string
switch value := destination.(type) {
case string:
v = value
case []byte:
v = string(value)
default:
return fmt.Errorf("unexpected data type %T", destination)
}
next, err := hujson.Standardize([]byte(v))
if err != nil {
return err
}
var n = new(T)
if err := json.Unmarshal(next, n); err != nil {
return err
}
h.v = v
h.t = n
return nil
}
+34
View File
@@ -5,16 +5,50 @@ import (
"database/sql/driver"
"encoding/json"
"fmt"
"github.com/jsiebens/ionscale/internal/util"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"sync"
"tailscale.com/tailcfg"
)
var (
_defaultDERPMapMu sync.RWMutex
_defaultDERPMap = WrapDERPMap(tailcfg.DERPMap{})
)
func SetDefaultDERPMap(v *tailcfg.DERPMap) {
if v == nil {
return
}
_defaultDERPMapMu.Lock()
defer _defaultDERPMapMu.Unlock()
_defaultDERPMap = WrapDERPMap(*v)
}
func GetDefaultDERPMap() DERPMap {
_defaultDERPMapMu.RLock()
defer _defaultDERPMapMu.RUnlock()
return _defaultDERPMap
}
type DERPMap struct {
Checksum string
DERPMap tailcfg.DERPMap
}
func (d DERPMap) GetDERPMap(_ context.Context) (*DERPMap, error) {
return &d, nil
}
func WrapDERPMap(d tailcfg.DERPMap) DERPMap {
return DERPMap{
Checksum: util.Checksum(d),
DERPMap: d,
}
}
func (hi *DERPMap) Scan(destination interface{}) error {
switch value := destination.(type) {
case []byte:
+18
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"reflect"
)
type DNSConfig struct {
@@ -14,6 +15,23 @@ type DNSConfig struct {
OverrideLocalDNS bool `json:"override_local_dns"`
Nameservers []string `json:"nameservers"`
Routes map[string][]string `json:"routes"`
SearchDomains []string `json:"search_domains"`
}
func (i *DNSConfig) Equal(x *DNSConfig) bool {
if i == nil && x == nil {
return true
}
if (i == nil) != (x == nil) {
return false
}
return i.MagicDNS == x.MagicDNS &&
i.HttpsCertsEnabled == x.HttpsCertsEnabled &&
i.OverrideLocalDNS == x.OverrideLocalDNS &&
reflect.DeepEqual(i.Nameservers, x.Nameservers) &&
reflect.DeepEqual(i.Routes, x.Routes) &&
reflect.DeepEqual(i.SearchDomains, x.SearchDomains)
}
func (i *DNSConfig) Scan(destination interface{}) error {
+11
View File
@@ -9,6 +9,7 @@ import (
"github.com/mitchellh/pointerstructure"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"reflect"
)
type Identity struct {
@@ -68,6 +69,16 @@ func (i *IAMPolicy) EvaluatePolicy(identity *Identity) (bool, error) {
return false, nil
}
func (i *IAMPolicy) Equal(x *IAMPolicy) bool {
if i == nil && x == nil {
return true
}
if (i == nil) != (x == nil) {
return false
}
return reflect.DeepEqual(i, x)
}
func (i *IAMPolicy) Scan(destination interface{}) error {
switch value := destination.(type) {
case []byte:
+35 -15
View File
@@ -13,6 +13,23 @@ import (
"time"
)
type MachineRepository interface {
SaveMachine(ctx context.Context, m *Machine) error
DeleteMachine(ctx context.Context, id uint64) (bool, error)
GetMachine(ctx context.Context, id uint64) (*Machine, error)
GetMachineByKeyAndUser(ctx context.Context, key string, userID uint64) (*Machine, error)
GetMachineByKeys(ctx context.Context, machineKey string, nodeKey string) (*Machine, error)
CountMachinesWithIPv4(ctx context.Context, ip string) (int64, error)
GetNextMachineNameIndex(ctx context.Context, tailnetID uint64, name string) (uint64, error)
ListMachineByTailnet(ctx context.Context, tailnetID uint64) (Machines, error)
CountMachineByTailnet(ctx context.Context, tailnetID uint64) (int64, error)
DeleteMachineByTailnet(ctx context.Context, tailnetID uint64) error
DeleteMachineByUser(ctx context.Context, userID uint64) error
ListMachinePeers(ctx context.Context, tailnetID uint64, machineID uint64) (Machines, error)
ListInactiveEphemeralMachines(ctx context.Context, checkpoint time.Time) (Machines, error)
SetMachineLastSeen(ctx context.Context, machineID uint64) error
}
type Machine struct {
ID uint64 `gorm:"primary_key"`
Name string
@@ -25,6 +42,7 @@ type Machine struct {
Tags Tags
KeyExpiryDisabled bool
Authorized bool
UseOSHostname bool `gorm:"default:true"`
HostInfo HostInfo
Endpoints Endpoints
@@ -107,7 +125,7 @@ func (m *Machine) IsAllowedExitNode() bool {
}
func (m *Machine) AdvertisedPrefixes() []string {
result := []string{}
var result []string
for _, r := range m.HostInfo.RoutableIPs {
if r.Bits() != 0 {
result = append(result, r.String())
@@ -310,7 +328,7 @@ func (HostInfo) GormDBDataType(db *gorm.DB, field *schema.Field) string {
return ""
}
type Endpoints []string
type Endpoints []netip.AddrPort
func (hi *Endpoints) Scan(destination interface{}) error {
switch value := destination.(type) {
@@ -357,7 +375,7 @@ func (r *repository) DeleteMachine(ctx context.Context, id uint64) (bool, error)
func (r *repository) GetMachine(ctx context.Context, machineID uint64) (*Machine, error) {
var m Machine
tx := r.withContext(ctx).Preload("Tailnet").Preload("User").First(&m, "id = ?", machineID)
tx := r.withContext(ctx).Preload("Tailnet").Preload("User").Preload("User.Account").Take(&m, machineID)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
@@ -376,7 +394,7 @@ func (r *repository) GetNextMachineNameIndex(ctx context.Context, tailnetID uint
tx := r.withContext(ctx).
Where("name = ? AND tailnet_id = ?", name, tailnetID).
Order("name_idx desc").
First(&m)
Take(&m)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return 0, nil
@@ -389,9 +407,9 @@ func (r *repository) GetNextMachineNameIndex(ctx context.Context, tailnetID uint
return m.NameIdx + 1, nil
}
func (r *repository) GetMachineByKey(ctx context.Context, tailnetID uint64, machineKey string) (*Machine, error) {
func (r *repository) GetMachineByKeyAndUser(ctx context.Context, machineKey string, userID uint64) (*Machine, error) {
var m Machine
tx := r.withContext(ctx).Preload("Tailnet").Preload("User").First(&m, "tailnet_id = ? AND machine_key = ?", tailnetID, machineKey)
tx := r.withContext(ctx).Preload("Tailnet").Preload("User").Take(&m, "machine_key = ? AND user_id = ?", machineKey, userID)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
@@ -406,7 +424,7 @@ func (r *repository) GetMachineByKey(ctx context.Context, tailnetID uint64, mach
func (r *repository) GetMachineByKeys(ctx context.Context, machineKey string, nodeKey string) (*Machine, error) {
var m Machine
tx := r.withContext(ctx).Preload("Tailnet").Preload("User").First(&m, "machine_key = ? AND node_key = ?", machineKey, nodeKey)
tx := r.withContext(ctx).Preload("Tailnet").Preload("User").Take(&m, "machine_key = ? AND node_key = ?", machineKey, nodeKey)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
@@ -458,9 +476,10 @@ func (r *repository) ListMachineByTailnet(ctx context.Context, tailnetID uint64)
tx := r.withContext(ctx).
Preload("Tailnet").
Preload("User").
Where("tailnet_id = ?", tailnetID).
Order("name asc, name_idx asc").
Joins("User").
Joins("User.Account").
Where("machines.tailnet_id = ?", tailnetID).
Order("machines.name asc, machines.name_idx asc").
Find(&machines)
if tx.Error != nil {
@@ -470,14 +489,15 @@ func (r *repository) ListMachineByTailnet(ctx context.Context, tailnetID uint64)
return machines, nil
}
func (r *repository) ListMachinePeers(ctx context.Context, tailnetID uint64, key string) (Machines, error) {
var machines = []Machine{}
func (r *repository) ListMachinePeers(ctx context.Context, tailnetID uint64, machineID uint64) (Machines, error) {
var machines []Machine
tx := r.withContext(ctx).
Preload("Tailnet").
Preload("User").
Where("tailnet_id = ? AND machine_key <> ?", tailnetID, key).
Order("id asc").
Joins("User").
Joins("User.Account").
Where("machines.tailnet_id = ? AND machines.id <> ?", tailnetID, machineID).
Order("machines.id asc").
Find(&machines)
if tx.Error != nil {
+8 -6
View File
@@ -12,6 +12,12 @@ import (
"time"
)
type RegistrationRequestRepository interface {
SaveRegistrationRequest(ctx context.Context, request *RegistrationRequest) error
GetRegistrationRequestByKey(ctx context.Context, key string) (*RegistrationRequest, error)
GetRegistrationRequestByMachineKey(ctx context.Context, key string) (*RegistrationRequest, error)
}
type RegistrationRequest struct {
MachineKey string `gorm:"primary_key"`
Key string
@@ -22,10 +28,6 @@ type RegistrationRequest struct {
UserID uint64
}
func (r *RegistrationRequest) IsFinished() bool {
return r.Authenticated || len(r.Error) != 0
}
type RegistrationRequestData tailcfg.RegisterRequest
func (hi *RegistrationRequestData) Scan(destination interface{}) error {
@@ -68,7 +70,7 @@ func (r *repository) SaveRegistrationRequest(ctx context.Context, request *Regis
func (r *repository) GetRegistrationRequestByKey(ctx context.Context, key string) (*RegistrationRequest, error) {
var m RegistrationRequest
tx := r.withContext(ctx).First(&m, "key = ?", key)
tx := r.withContext(ctx).Take(&m, "key = ?", key)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
@@ -83,7 +85,7 @@ func (r *repository) GetRegistrationRequestByKey(ctx context.Context, key string
func (r *repository) GetRegistrationRequestByMachineKey(ctx context.Context, key string) (*RegistrationRequest, error) {
var m RegistrationRequest
tx := r.withContext(ctx).First(&m, "machine_key = ?", key)
tx := r.withContext(ctx).Take(&m, "machine_key = ?", key)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
+15 -116
View File
@@ -2,103 +2,43 @@ package domain
import (
"context"
"encoding/json"
"github.com/jsiebens/ionscale/internal/util"
"gorm.io/gorm"
"net/http"
"sync"
"tailscale.com/tailcfg"
"time"
"gorm.io/gorm/clause"
)
type Repository interface {
AccountRepository
ApiKeyRepository
SystemApiKeyRepository
AuthKeyRepository
MachineRepository
TailnetRepository
UserRepository
AuthenticationRequestRepository
RegistrationRequestRepository
SSHActionRequestRepository
GetControlKeys(ctx context.Context) (*ControlKeys, error)
SetControlKeys(ctx context.Context, keys *ControlKeys) error
GetJSONWebKeySet(ctx context.Context) (*JSONWebKeys, error)
SetJSONWebKeySet(ctx context.Context, keys *JSONWebKeys) error
GetDERPMap(ctx context.Context) (*DERPMap, error)
SetDERPMap(ctx context.Context, v *DERPMap) error
GetAccount(ctx context.Context, accountID uint64) (*Account, error)
GetOrCreateAccount(ctx context.Context, externalID, loginName string) (*Account, bool, error)
SaveTailnet(ctx context.Context, tailnet *Tailnet) error
GetOrCreateTailnet(ctx context.Context, name string, iamPolicy IAMPolicy) (*Tailnet, bool, error)
GetTailnet(ctx context.Context, id uint64) (*Tailnet, error)
GetTailnetByAlias(ctx context.Context, alias string) (*Tailnet, error)
ListTailnets(ctx context.Context) ([]Tailnet, error)
DeleteTailnet(ctx context.Context, id uint64) error
SaveSystemApiKey(ctx context.Context, key *SystemApiKey) error
LoadSystemApiKey(ctx context.Context, key string) (*SystemApiKey, error)
SaveApiKey(ctx context.Context, key *ApiKey) error
LoadApiKey(ctx context.Context, key string) (*ApiKey, error)
DeleteApiKeysByTailnet(ctx context.Context, tailnetID uint64) error
DeleteApiKeysByUser(ctx context.Context, userID uint64) error
GetAuthKey(ctx context.Context, id uint64) (*AuthKey, error)
SaveAuthKey(ctx context.Context, key *AuthKey) error
DeleteAuthKey(ctx context.Context, id uint64) (bool, error)
DeleteAuthKeysByTailnet(ctx context.Context, tailnetID uint64) error
DeleteAuthKeysByUser(ctx context.Context, userID uint64) error
ListAuthKeys(ctx context.Context, tailnetID uint64) ([]AuthKey, error)
ListAuthKeysByTailnetAndUser(ctx context.Context, tailnetID, userID uint64) ([]AuthKey, error)
LoadAuthKey(ctx context.Context, key string) (*AuthKey, error)
GetOrCreateServiceUser(ctx context.Context, tailnet *Tailnet) (*User, bool, error)
GetOrCreateUserWithAccount(ctx context.Context, tailnet *Tailnet, account *Account) (*User, bool, error)
GetUser(ctx context.Context, userID uint64) (*User, error)
DeleteUser(ctx context.Context, userID uint64) error
ListUsers(ctx context.Context, tailnetID uint64) (Users, error)
DeleteUsersByTailnet(ctx context.Context, tailnetID uint64) error
SaveMachine(ctx context.Context, m *Machine) error
DeleteMachine(ctx context.Context, id uint64) (bool, error)
GetMachine(ctx context.Context, id uint64) (*Machine, error)
GetMachineByKey(ctx context.Context, tailnetID uint64, key string) (*Machine, error)
GetMachineByKeys(ctx context.Context, machineKey string, nodeKey string) (*Machine, error)
CountMachinesWithIPv4(ctx context.Context, ip string) (int64, error)
GetNextMachineNameIndex(ctx context.Context, tailnetID uint64, name string) (uint64, error)
ListMachineByTailnet(ctx context.Context, tailnetID uint64) (Machines, error)
CountMachineByTailnet(ctx context.Context, tailnetID uint64) (int64, error)
DeleteMachineByTailnet(ctx context.Context, tailnetID uint64) error
DeleteMachineByUser(ctx context.Context, userID uint64) error
ListMachinePeers(ctx context.Context, tailnetID uint64, key string) (Machines, error)
ListInactiveEphemeralMachines(ctx context.Context, checkpoint time.Time) (Machines, error)
SetMachineLastSeen(ctx context.Context, machineID uint64) error
SaveRegistrationRequest(ctx context.Context, request *RegistrationRequest) error
GetRegistrationRequestByKey(ctx context.Context, key string) (*RegistrationRequest, error)
GetRegistrationRequestByMachineKey(ctx context.Context, key string) (*RegistrationRequest, error)
SaveAuthenticationRequest(ctx context.Context, session *AuthenticationRequest) error
GetAuthenticationRequest(ctx context.Context, key string) (*AuthenticationRequest, error)
DeleteAuthenticationRequest(ctx context.Context, key string) error
SaveSSHActionRequest(ctx context.Context, session *SSHActionRequest) error
GetSSHActionRequest(ctx context.Context, key string) (*SSHActionRequest, error)
DeleteSSHActionRequest(ctx context.Context, key string) error
Transaction(func(rp Repository) error) error
}
func NewRepository(db *gorm.DB) Repository {
return &repository{
db: db,
defaultDERPMap: &derpMapCache{},
db: db,
}
}
type repository struct {
db *gorm.DB
defaultDERPMap *derpMapCache
db *gorm.DB
}
func (r *repository) withContext(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx)
return r.db.WithContext(ctx).Omit(clause.Associations)
}
func (r *repository) Transaction(action func(Repository) error) error {
@@ -106,44 +46,3 @@ func (r *repository) Transaction(action func(Repository) error) error {
return action(NewRepository(tx))
})
}
type derpMapCache struct {
sync.RWMutex
value *DERPMap
}
func (d *derpMapCache) Get() (*DERPMap, error) {
d.RLock()
if d.value != nil {
d.RUnlock()
return d.value, nil
}
d.RUnlock()
d.Lock()
defer d.Unlock()
getJson := func(url string, target interface{}) error {
c := http.Client{Timeout: 5 * time.Second}
r, err := c.Get(url)
if err != nil {
return err
}
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(target)
}
m := &tailcfg.DERPMap{}
if err := getJson("https://controlplane.tailscale.com/derpmap/default", m); err != nil {
return nil, err
}
d.value = &DERPMap{
Checksum: util.Checksum(m),
DERPMap: *m,
}
return d.value, nil
}
-24
View File
@@ -81,30 +81,6 @@ func (r *repository) SetJSONWebKeySet(ctx context.Context, v *JSONWebKeys) error
return r.setServerConfig(ctx, jwksConfigKey, v)
}
func (r *repository) GetDERPMap(ctx context.Context) (*DERPMap, error) {
var m DERPMap
err := r.getServerConfig(ctx, derpMapConfigKey, &m)
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.defaultDERPMap.Get()
}
if m.Checksum == "" {
return r.defaultDERPMap.Get()
}
if err != nil {
return nil, err
}
return &m, nil
}
func (r *repository) SetDERPMap(ctx context.Context, v *DERPMap) error {
return r.setServerConfig(ctx, "derp_map", v)
}
func (r *repository) getServerConfig(ctx context.Context, s configKey, v interface{}) error {
var m ServerConfig
tx := r.withContext(ctx).Take(&m, "key = ?", s)
+7 -1
View File
@@ -7,6 +7,12 @@ import (
"time"
)
type SSHActionRequestRepository interface {
SaveSSHActionRequest(ctx context.Context, session *SSHActionRequest) error
GetSSHActionRequest(ctx context.Context, key string) (*SSHActionRequest, error)
DeleteSSHActionRequest(ctx context.Context, key string) error
}
type SSHActionRequest struct {
Key string `gorm:"primary_key"`
Action string
@@ -27,7 +33,7 @@ func (r *repository) SaveSSHActionRequest(ctx context.Context, session *SSHActio
func (r *repository) GetSSHActionRequest(ctx context.Context, key string) (*SSHActionRequest, error) {
var m SSHActionRequest
tx := r.withContext(ctx).First(&m, "key = ?", key)
tx := r.withContext(ctx).Take(&m, "key = ?", key)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
+6 -1
View File
@@ -32,6 +32,11 @@ func CreateSystemApiKey(account *Account, expiresAt *time.Time) (string, *System
}
}
type SystemApiKeyRepository interface {
SaveSystemApiKey(ctx context.Context, key *SystemApiKey) error
LoadSystemApiKey(ctx context.Context, key string) (*SystemApiKey, error)
}
type SystemApiKey struct {
ID uint64 `gorm:"primary_key"`
Key string
@@ -69,7 +74,7 @@ func (r *repository) LoadSystemApiKey(ctx context.Context, token string) (*Syste
}
var m SystemApiKey
tx := r.withContext(ctx).Preload("Account").First(&m, "key = ?", key)
tx := r.withContext(ctx).Preload("Account").Take(&m, "key = ?", key)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
+14 -23
View File
@@ -3,7 +3,6 @@ package domain
import (
"context"
"errors"
"github.com/jsiebens/ionscale/internal/util"
"gorm.io/gorm"
"net/mail"
"strings"
@@ -14,8 +13,8 @@ type Tailnet struct {
ID uint64 `gorm:"primary_key"`
Name string
DNSConfig DNSConfig
IAMPolicy IAMPolicy
ACLPolicy ACLPolicy
IAMPolicy HuJSON[IAMPolicy]
ACLPolicy HuJSON[ACLPolicy]
DERPMap DERPMap
ServiceCollectionEnabled bool
FileSharingEnabled bool
@@ -23,9 +22,17 @@ type Tailnet struct {
MachineAuthorizationEnabled bool
}
func (t Tailnet) GetDERPMap(ctx context.Context, fallack DefaultDERPMap) (*DERPMap, error) {
type TailnetRepository interface {
SaveTailnet(ctx context.Context, tailnet *Tailnet) error
GetTailnet(ctx context.Context, id uint64) (*Tailnet, error)
GetTailnetByName(ctx context.Context, name string) (*Tailnet, error)
ListTailnets(ctx context.Context) ([]Tailnet, error)
DeleteTailnet(ctx context.Context, id uint64) error
}
func (t Tailnet) GetDERPMap(ctx context.Context, fallback DefaultDERPMap) (*DERPMap, error) {
if t.DERPMap.Checksum == "" {
return fallack.GetDERPMap(ctx)
return fallback.GetDERPMap(ctx)
} else {
return &t.DERPMap, nil
}
@@ -58,22 +65,6 @@ func (r *repository) SaveTailnet(ctx context.Context, tailnet *Tailnet) error {
return nil
}
func (r *repository) GetOrCreateTailnet(ctx context.Context, name string, iamPolicy IAMPolicy) (*Tailnet, bool, error) {
tailnet := &Tailnet{}
id := util.NextID()
tx := r.withContext(ctx).
Where(Tailnet{Name: name}).
Attrs(Tailnet{ID: id, ACLPolicy: DefaultPolicy(), IAMPolicy: iamPolicy}).
FirstOrCreate(tailnet)
if tx.Error != nil {
return nil, false, tx.Error
}
return tailnet, tailnet.ID == id, nil
}
func (r *repository) GetTailnet(ctx context.Context, id uint64) (*Tailnet, error) {
var t Tailnet
tx := r.withContext(ctx).Take(&t, "id = ?", id)
@@ -89,9 +80,9 @@ func (r *repository) GetTailnet(ctx context.Context, id uint64) (*Tailnet, error
return &t, nil
}
func (r *repository) GetTailnetByAlias(ctx context.Context, alias string) (*Tailnet, error) {
func (r *repository) GetTailnetByName(ctx context.Context, name string) (*Tailnet, error) {
var t Tailnet
tx := r.withContext(ctx).Take(&t, "alias = ?", alias)
tx := r.withContext(ctx).Take(&t, "name = ?", name)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
+33 -8
View File
@@ -5,6 +5,7 @@ import (
"errors"
"github.com/jsiebens/ionscale/internal/util"
"gorm.io/gorm"
"time"
)
type SystemRole string
@@ -37,14 +38,25 @@ func (s UserRole) IsAdmin() bool {
return s == UserRoleAdmin
}
type UserRepository interface {
GetOrCreateServiceUser(ctx context.Context, tailnet *Tailnet) (*User, bool, error)
GetOrCreateUserWithAccount(ctx context.Context, tailnet *Tailnet, account *Account) (*User, bool, error)
GetUser(ctx context.Context, userID uint64) (*User, error)
DeleteUser(ctx context.Context, userID uint64) error
ListUsers(ctx context.Context, tailnetID uint64) (Users, error)
DeleteUsersByTailnet(ctx context.Context, tailnetID uint64) error
SetUserLastAuthenticated(ctx context.Context, userID uint64, timestamp time.Time) error
}
type User struct {
ID uint64 `gorm:"primary_key"`
Name string
UserType UserType
TailnetID uint64
Tailnet Tailnet
AccountID *uint64
Account *Account
ID uint64 `gorm:"primary_key"`
Name string
UserType UserType
LastAuthenticated *time.Time
TailnetID uint64
Tailnet Tailnet
AccountID *uint64
Account *Account
}
type Users []User
@@ -100,7 +112,7 @@ func (r *repository) GetOrCreateUserWithAccount(ctx context.Context, tailnet *Ta
func (r *repository) GetUser(ctx context.Context, userID uint64) (*User, error) {
var m User
tx := r.withContext(ctx).Preload("Tailnet").Preload("Account").First(&m, "id = ?", userID)
tx := r.withContext(ctx).Preload("Tailnet").Preload("Account").Take(&m, "id = ?", userID)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
@@ -117,3 +129,16 @@ func (r *repository) DeleteUser(ctx context.Context, userID uint64) error {
tx := r.withContext(ctx).Delete(&User{ID: userID})
return tx.Error
}
func (r *repository) SetUserLastAuthenticated(ctx context.Context, userID uint64, timestamp time.Time) error {
tx := r.withContext(ctx).
Model(User{}).
Where("id = ?", userID).
Updates(map[string]interface{}{"last_authenticated": &timestamp})
if tx.Error != nil {
return tx.Error
}
return nil
}
-42
View File
@@ -1,42 +0,0 @@
package errors
import (
"fmt"
"runtime"
)
type Error struct {
Cause error
Location string
}
func Wrap(err error, skip int) error {
if err == nil {
return nil
}
c := &Error{
Cause: err,
Location: getLocation(skip),
}
return c
}
func (w *Error) Error() string {
return w.Cause.Error()
}
func (f *Error) Unwrap() error {
return f.Cause
}
func (f *Error) Format(s fmt.State, verb rune) {
fmt.Fprintf(s, "%s\n", f.Cause.Error())
fmt.Fprintf(s, "\t%s\n", f.Location)
}
func getLocation(skip int) string {
_, file, line, _ := runtime.Caller(2 + skip)
return fmt.Sprintf("%s:%d", file, line)
}
+188 -142
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"github.com/jsiebens/ionscale/internal/addr"
"github.com/jsiebens/ionscale/internal/auth"
"github.com/jsiebens/ionscale/internal/errors"
tpl "github.com/jsiebens/ionscale/internal/templates"
"github.com/labstack/echo/v4/middleware"
"github.com/mr-tron/base58"
"net/http"
@@ -41,59 +41,84 @@ type AuthenticationHandlers struct {
systemIAMPolicy *domain.IAMPolicy
}
type AuthFormData struct {
ProviderAvailable bool
Csrf string
type AuthInput struct {
Key string `param:"key"`
Flow AuthFlow `param:"flow"`
AuthKey string `query:"ak" form:"ak"`
Oidc bool `query:"oidc" form:"oidc"`
}
type TailnetSelectionData struct {
AccountID uint64
Tailnets []domain.Tailnet
SystemAdmin bool
Csrf string
type EndAuthForm struct {
AccountID uint64 `form:"aid"`
TailnetID uint64 `form:"tid"`
AsSystemAdmin bool `form:"sad"`
AuthKey string `form:"ak"`
State string `form:"state"`
}
type oauthState struct {
Key string
Flow string
Flow AuthFlow
}
type AuthFlow string
const (
AuthFlowMachineRegistration = "r"
AuthFlowClient = "c"
AuthFlowSSHCheckFlow = "s"
)
func (h *AuthenticationHandlers) StartAuth(c echo.Context) error {
ctx := c.Request().Context()
flow := c.Param("flow")
key := c.Param("key")
var input AuthInput
if err := c.Bind(&input); err != nil {
return logError(err)
}
// machine registration auth flow
if flow == "r" || flow == "" {
if req, err := h.repository.GetRegistrationRequestByKey(ctx, key); err != nil || req == nil {
return errors.Wrap(err, 0)
if input.Flow == AuthFlowMachineRegistration {
req, err := h.repository.GetRegistrationRequestByKey(ctx, input.Key)
if err != nil || req == nil {
return logError(err)
}
if input.Oidc && h.authProvider != nil {
goto startOidc
}
if input.AuthKey != "" {
return h.endMachineRegistrationFlow(c, EndAuthForm{AuthKey: input.AuthKey}, req)
}
csrf := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
return c.Render(http.StatusOK, "auth.html", &AuthFormData{ProviderAvailable: h.authProvider != nil, Csrf: csrf})
return c.Render(http.StatusOK, "", tpl.Auth(h.authProvider != nil, csrf))
}
// cli auth flow
if flow == "c" {
if s, err := h.repository.GetAuthenticationRequest(ctx, key); err != nil || s == nil {
return errors.Wrap(err, 0)
if input.Flow == AuthFlowClient {
if s, err := h.repository.GetAuthenticationRequest(ctx, input.Key); err != nil || s == nil {
return logError(err)
}
}
// ssh check auth flow
if flow == "s" {
if s, err := h.repository.GetSSHActionRequest(ctx, key); err != nil || s == nil {
return errors.Wrap(err, 0)
if input.Flow == AuthFlowSSHCheckFlow {
if s, err := h.repository.GetSSHActionRequest(ctx, input.Key); err != nil || s == nil {
return logError(err)
}
}
if h.authProvider == nil {
return errors.Wrap(fmt.Errorf("unable to start auth flow as no auth provider is configured"), 0)
return logError(fmt.Errorf("unable to start auth flow as no auth provider is configured"))
}
state, err := h.createState(flow, key)
startOidc:
state, err := h.createState(input.Flow, input.Key)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
redirectUrl := h.authProvider.GetLoginURL(h.config.CreateUrl("/a/callback"), state)
@@ -104,23 +129,24 @@ func (h *AuthenticationHandlers) StartAuth(c echo.Context) error {
func (h *AuthenticationHandlers) ProcessAuth(c echo.Context) error {
ctx := c.Request().Context()
key := c.Param("key")
authKey := c.FormValue("ak")
interactive := c.FormValue("s")
var input AuthInput
if err := c.Bind(&input); err != nil {
return logError(err)
}
req, err := h.repository.GetRegistrationRequestByKey(ctx, key)
req, err := h.repository.GetRegistrationRequestByKey(ctx, input.Key)
if err != nil || req == nil {
return errors.Wrap(err, 0)
return logError(err)
}
if authKey != "" {
return h.endMachineRegistrationFlow(c, req, &oauthState{Key: key})
if input.AuthKey != "" {
return h.endMachineRegistrationFlow(c, EndAuthForm{AuthKey: input.AuthKey}, req)
}
if interactive != "" {
state, err := h.createState("r", key)
if input.Oidc {
state, err := h.createState(input.Flow, input.Key)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
redirectUrl := h.authProvider.GetLoginURL(h.config.CreateUrl("/a/callback"), state)
@@ -128,7 +154,7 @@ func (h *AuthenticationHandlers) ProcessAuth(c echo.Context) error {
return c.Redirect(http.StatusFound, redirectUrl)
}
return c.Redirect(http.StatusFound, "/a/"+key)
return c.Redirect(http.StatusFound, fmt.Sprintf("/a/%s/%s", input.Flow, input.Key))
}
func (h *AuthenticationHandlers) Callback(c echo.Context) error {
@@ -142,15 +168,19 @@ func (h *AuthenticationHandlers) Callback(c echo.Context) error {
user, err := h.exchangeUser(code)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
account, _, err := h.repository.GetOrCreateAccount(ctx, user.ID, user.Name)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if state.Flow == "s" {
if err := h.repository.SetAccountLastAuthenticated(ctx, account.ID); err != nil {
return logError(err)
}
if state.Flow == AuthFlowSSHCheckFlow {
sshActionReq, err := h.repository.GetSSHActionRequest(ctx, state.Key)
if err != nil || sshActionReq == nil {
return c.Redirect(http.StatusFound, "/a/error?e=ua")
@@ -158,32 +188,43 @@ func (h *AuthenticationHandlers) Callback(c echo.Context) error {
machine, err := h.repository.GetMachine(ctx, sshActionReq.SrcMachineID)
if err != nil || sshActionReq == nil {
return errors.Wrap(err, 0)
return logError(err)
}
if !machine.HasTags() && machine.User.AccountID != nil && *machine.User.AccountID == account.ID {
sshActionReq.Action = "accept"
if err := h.repository.SaveSSHActionRequest(ctx, sshActionReq); err != nil {
return errors.Wrap(err, 0)
err := h.repository.Transaction(func(rp domain.Repository) error {
if err := rp.SetUserLastAuthenticated(ctx, machine.UserID, time.Now().UTC()); err != nil {
return err
}
if err := rp.SaveSSHActionRequest(ctx, sshActionReq); err != nil {
return err
}
return nil
})
if err != nil {
return logError(err)
}
return c.Redirect(http.StatusFound, "/a/success")
}
sshActionReq.Action = "reject"
if err := h.repository.SaveSSHActionRequest(ctx, sshActionReq); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
return c.Redirect(http.StatusFound, "/a/error?e=nmo")
}
tailnets, err := h.listAvailableTailnets(ctx, user)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
csrf := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
if state.Flow == "r" {
if state.Flow == AuthFlowMachineRegistration {
if len(tailnets) == 0 {
registrationRequest, err := h.repository.GetRegistrationRequestByKey(ctx, state.Key)
if err == nil && registrationRequest != nil {
@@ -192,18 +233,25 @@ func (h *AuthenticationHandlers) Callback(c echo.Context) error {
}
return c.Redirect(http.StatusFound, "/a/error?e=ua")
}
return c.Render(http.StatusOK, "tailnets.html", &TailnetSelectionData{
Csrf: csrf,
Tailnets: tailnets,
SystemAdmin: false,
AccountID: account.ID,
})
if len(tailnets) == 1 {
req, err := h.repository.GetRegistrationRequestByKey(ctx, state.Key)
if err != nil {
return logError(err)
}
if req == nil {
return logError(fmt.Errorf("invalid registration key"))
}
return h.endMachineRegistrationFlow(c, EndAuthForm{AccountID: account.ID, TailnetID: tailnets[0].ID}, req)
}
return c.Render(http.StatusOK, "", tpl.Tailnets(account.ID, false, tailnets, csrf))
}
if state.Flow == "c" {
isSystemAdmin, err := h.isSystemAdmin(ctx, user)
if state.Flow == AuthFlowClient {
isSystemAdmin, err := h.isSystemAdmin(user)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if !isSystemAdmin && len(tailnets) == 0 {
@@ -214,106 +262,77 @@ func (h *AuthenticationHandlers) Callback(c echo.Context) error {
}
return c.Redirect(http.StatusFound, "/a/error?e=ua")
}
return c.Render(http.StatusOK, "tailnets.html", &TailnetSelectionData{
Csrf: csrf,
Tailnets: tailnets,
SystemAdmin: isSystemAdmin,
AccountID: account.ID,
})
return c.Render(http.StatusOK, "", tpl.Tailnets(account.ID, isSystemAdmin, tailnets, csrf))
}
return echo.NewHTTPError(http.StatusNotFound)
}
func (h *AuthenticationHandlers) isSystemAdmin(ctx context.Context, u *auth.User) (bool, error) {
return h.systemIAMPolicy.EvaluatePolicy(&domain.Identity{UserID: u.ID, Email: u.Name, Attr: u.Attr})
}
func (h *AuthenticationHandlers) listAvailableTailnets(ctx context.Context, u *auth.User) ([]domain.Tailnet, error) {
var result = []domain.Tailnet{}
tailnets, err := h.repository.ListTailnets(ctx)
if err != nil {
return nil, err
}
for _, t := range tailnets {
approved, err := t.IAMPolicy.EvaluatePolicy(&domain.Identity{UserID: u.ID, Email: u.Name, Attr: u.Attr})
if err != nil {
return nil, err
}
if approved {
result = append(result, t)
}
}
return result, nil
}
func (h *AuthenticationHandlers) EndOAuth(c echo.Context) error {
func (h *AuthenticationHandlers) EndAuth(c echo.Context) error {
ctx := c.Request().Context()
state, err := h.readState(c.QueryParam("state"))
var form EndAuthForm
if err := c.Bind(&form); err != nil {
return logError(err)
}
state, err := h.readState(form.State)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid state parameter")
}
if state.Flow == "r" {
if state.Flow == AuthFlowMachineRegistration {
req, err := h.repository.GetRegistrationRequestByKey(ctx, state.Key)
if err != nil || req == nil {
return errors.Wrap(err, 0)
return logError(err)
}
return h.endMachineRegistrationFlow(c, req, state)
return h.endMachineRegistrationFlow(c, form, req)
}
req, err := h.repository.GetAuthenticationRequest(ctx, state.Key)
if err != nil || req == nil {
return errors.Wrap(err, 0)
if state.Flow == AuthFlowClient {
req, err := h.repository.GetAuthenticationRequest(ctx, state.Key)
if err != nil || req == nil {
return logError(err)
}
return h.endCliAuthenticationFlow(c, form, req)
}
return h.endCliAuthenticationFlow(c, req, state)
return echo.NewHTTPError(http.StatusBadRequest, "Invalid state parameter")
}
func (h *AuthenticationHandlers) Success(c echo.Context) error {
s := c.QueryParam("s")
switch s {
case "nma":
return c.Render(http.StatusOK, "newmachine.html", nil)
return c.Render(http.StatusOK, "", tpl.NewMachine())
}
return c.Render(http.StatusOK, "success.html", nil)
return c.Render(http.StatusOK, "", tpl.Success())
}
func (h *AuthenticationHandlers) Error(c echo.Context) error {
e := c.QueryParam("e")
switch e {
case "iak":
return c.Render(http.StatusForbidden, "invalidauthkey.html", nil)
return c.Render(http.StatusForbidden, "", tpl.InvalidAuthKey())
case "ua":
return c.Render(http.StatusForbidden, "unauthorized.html", nil)
return c.Render(http.StatusForbidden, "", tpl.Unauthorized())
case "nto":
return c.Render(http.StatusForbidden, "notagowner.html", nil)
return c.Render(http.StatusForbidden, "", tpl.NotTagOwner())
case "nmo":
return c.Render(http.StatusForbidden, "notmachineowner.html", nil)
return c.Render(http.StatusForbidden, "", tpl.NotMachineOwner())
}
return c.Render(http.StatusOK, "error.html", nil)
return c.Render(http.StatusOK, "", tpl.Error())
}
type TailnetSelectionForm struct {
AccountID uint64 `form:"aid"`
TailnetID uint64 `form:"tid"`
AsSystemAdmin bool `form:"sad"`
AuthKey string `form:"ak"`
}
func (h *AuthenticationHandlers) endCliAuthenticationFlow(c echo.Context, req *domain.AuthenticationRequest, state *oauthState) error {
func (h *AuthenticationHandlers) endCliAuthenticationFlow(c echo.Context, form EndAuthForm, req *domain.AuthenticationRequest) error {
ctx := c.Request().Context()
var form TailnetSelectionForm
if err := c.Bind(&form); err != nil {
return errors.Wrap(err, 0)
}
account, err := h.repository.GetAccount(ctx, form.AccountID)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
// continue as system admin?
@@ -324,27 +343,27 @@ func (h *AuthenticationHandlers) endCliAuthenticationFlow(c echo.Context, req *d
err := h.repository.Transaction(func(rp domain.Repository) error {
if err := rp.SaveSystemApiKey(ctx, apiKey); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if err := rp.SaveAuthenticationRequest(ctx, req); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
return nil
})
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
return c.Redirect(http.StatusFound, "/a/success")
}
tailnet, err := h.repository.GetTailnet(ctx, form.TailnetID)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
user, _, err := h.repository.GetOrCreateUserWithAccount(ctx, tailnet, account)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
expiresAt := time.Now().Add(24 * time.Hour)
@@ -353,6 +372,9 @@ func (h *AuthenticationHandlers) endCliAuthenticationFlow(c echo.Context, req *d
req.TailnetID = &tailnet.ID
err = h.repository.Transaction(func(rp domain.Repository) error {
if err := rp.SetUserLastAuthenticated(ctx, user.ID, time.Now().UTC()); err != nil {
return err
}
if err := rp.SaveApiKey(ctx, apiKey); err != nil {
return err
}
@@ -362,20 +384,15 @@ func (h *AuthenticationHandlers) endCliAuthenticationFlow(c echo.Context, req *d
return nil
})
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
return c.Redirect(http.StatusFound, "/a/success")
}
func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, registrationRequest *domain.RegistrationRequest, state *oauthState) error {
func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, form EndAuthForm, registrationRequest *domain.RegistrationRequest) error {
ctx := c.Request().Context()
var form TailnetSelectionForm
if err := c.Bind(&form); err != nil {
return errors.Wrap(err, 0)
}
req := tailcfg.RegisterRequest(registrationRequest.Data)
machineKey := registrationRequest.MachineKey
nodeKey := req.NodeKey.String()
@@ -389,7 +406,7 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
if form.AuthKey != "" {
authKey, err := h.repository.LoadAuthKey(ctx, form.AuthKey)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if authKey == nil {
@@ -398,7 +415,7 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
registrationRequest.Error = "invalid auth key"
if err := h.repository.SaveRegistrationRequest(ctx, registrationRequest); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
return c.Redirect(http.StatusFound, "/a/error?e=iak")
@@ -412,17 +429,17 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
} else {
selectedTailnet, err := h.repository.GetTailnet(ctx, form.TailnetID)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
account, err := h.repository.GetAccount(ctx, form.AccountID)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
selectedUser, _, err := h.repository.GetOrCreateUserWithAccount(ctx, selectedTailnet, account)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
user = selectedUser
@@ -430,22 +447,22 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
ephemeral = false
}
if err := tailnet.ACLPolicy.CheckTagOwners(registrationRequest.Data.Hostinfo.RequestTags, user); err != nil {
if err := tailnet.ACLPolicy.Get().CheckTagOwners(registrationRequest.Data.Hostinfo.RequestTags, user); err != nil {
registrationRequest.Authenticated = false
registrationRequest.Error = err.Error()
if err := h.repository.SaveRegistrationRequest(ctx, registrationRequest); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
return c.Redirect(http.StatusFound, "/a/error?e=nto")
}
autoAllowIPs := tailnet.ACLPolicy.FindAutoApprovedIPs(req.Hostinfo.RoutableIPs, tags, user)
autoAllowIPs := tailnet.ACLPolicy.Get().FindAutoApprovedIPs(req.Hostinfo.RoutableIPs, tags, user)
var m *domain.Machine
m, err := h.repository.GetMachineByKey(ctx, tailnet.ID, machineKey)
m, err := h.repository.GetMachineByKeyAndUser(ctx, machineKey, user.ID)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
now := time.Now().UTC()
@@ -458,13 +475,14 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
sanitizeHostname := dnsname.SanitizeHostname(req.Hostinfo.Hostname)
nameIdx, err := h.repository.GetNextMachineNameIndex(ctx, tailnet.ID, sanitizeHostname)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
m = &domain.Machine{
ID: util.NextID(),
Name: sanitizeHostname,
NameIdx: nameIdx,
UseOSHostname: true,
MachineKey: machineKey,
NodeKey: nodeKey,
Ephemeral: ephemeral || req.Ephemeral,
@@ -476,13 +494,15 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
KeyExpiryDisabled: len(tags) != 0,
Authorized: !tailnet.MachineAuthorizationEnabled || authorized,
User: *user,
Tailnet: *tailnet,
User: *user,
UserID: user.ID,
Tailnet: *tailnet,
TailnetID: tailnet.ID,
}
ipv4, ipv6, err := addr.SelectIP(checkIP(ctx, h.repository.CountMachinesWithIPv4))
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
m.IPv4 = domain.IP{Addr: ipv4}
m.IPv6 = domain.IP{Addr: ipv6}
@@ -492,10 +512,10 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
tags := append(registeredTags, advertisedTags...)
sanitizeHostname := dnsname.SanitizeHostname(req.Hostinfo.Hostname)
if m.Name != sanitizeHostname {
if m.UseOSHostname && m.Name != sanitizeHostname {
nameIdx, err := h.repository.GetNextMachineNameIndex(ctx, tailnet.ID, sanitizeHostname)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
m.Name = sanitizeHostname
m.NameIdx = nameIdx
@@ -517,6 +537,10 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
registrationRequest.Error = ""
registrationRequest.UserID = user.ID
if err := rp.SetUserLastAuthenticated(ctx, m.UserID, time.Now().UTC()); err != nil {
return err
}
if err := rp.SaveMachine(ctx, m); err != nil {
return err
}
@@ -529,7 +553,7 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
})
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if m.Authorized {
@@ -539,6 +563,28 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
}
}
func (h *AuthenticationHandlers) isSystemAdmin(u *auth.User) (bool, error) {
return h.systemIAMPolicy.EvaluatePolicy(&domain.Identity{UserID: u.ID, Email: u.Name, Attr: u.Attr})
}
func (h *AuthenticationHandlers) listAvailableTailnets(ctx context.Context, u *auth.User) ([]domain.Tailnet, error) {
var result = []domain.Tailnet{}
tailnets, err := h.repository.ListTailnets(ctx)
if err != nil {
return nil, err
}
for _, t := range tailnets {
approved, err := t.IAMPolicy.Get().EvaluatePolicy(&domain.Identity{UserID: u.ID, Email: u.Name, Attr: u.Attr})
if err != nil {
return nil, err
}
if approved {
result = append(result, t)
}
}
return result, nil
}
func (h *AuthenticationHandlers) exchangeUser(code string) (*auth.User, error) {
redirectUrl := h.config.CreateUrl("/a/callback")
@@ -550,7 +596,7 @@ func (h *AuthenticationHandlers) exchangeUser(code string) (*auth.User, error) {
return user, nil
}
func (h *AuthenticationHandlers) createState(flow string, key string) (string, error) {
func (h *AuthenticationHandlers) createState(flow AuthFlow, key string) (string, error) {
stateMap := oauthState{Key: key, Flow: flow}
marshal, err := json.Marshal(&stateMap)
if err != nil {
+46
View File
@@ -0,0 +1,46 @@
package handlers
import (
"fmt"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"net/http"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/types/key"
)
func NewDERPHandler() *DERPHandlers {
logger := zap.L().Named("derp")
return &DERPHandlers{
s: derp.NewServer(key.NewNode(), func(format string, args ...any) {
logger.Debug(fmt.Sprintf(format, args...))
}),
}
}
type DERPHandlers struct {
s *derp.Server
}
func (h *DERPHandlers) Handler(c echo.Context) error {
derphttp.Handler(h.s).ServeHTTP(c.Response(), c.Request())
return nil
}
func (h *DERPHandlers) LatencyCheck(c echo.Context) error {
return c.String(http.StatusOK, "")
}
func (h *DERPHandlers) DebugTraffic(c echo.Context) error {
h.s.ServeDebugTraffic(c.Response(), c.Request())
return nil
}
func (h *DERPHandlers) DebugCheck(c echo.Context) error {
if err := h.s.ConsistencyCheck(); err != nil {
return err
}
return c.String(http.StatusOK, "DERP Server ConsistencyCheck okay")
}
+13 -17
View File
@@ -1,40 +1,36 @@
package handlers
import (
"github.com/jsiebens/ionscale/internal/bind"
"github.com/jsiebens/ionscale/internal/dns"
"github.com/jsiebens/ionscale/internal/errors"
"github.com/labstack/echo/v4"
"net"
"net/http"
"strings"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"time"
)
func NewDNSHandlers(createBinder bind.Factory, provider dns.Provider) *DNSHandlers {
func NewDNSHandlers(_ key.MachinePublic, provider dns.Provider) *DNSHandlers {
return &DNSHandlers{
createBinder: createBinder,
provider: provider,
provider: provider,
}
}
type DNSHandlers struct {
createBinder bind.Factory
provider dns.Provider
provider dns.Provider
}
func (h *DNSHandlers) SetDNS(c echo.Context) error {
ctx := c.Request().Context()
binder, err := h.createBinder(c)
if err != nil {
return errors.Wrap(err, 0)
req := &tailcfg.SetDNSRequest{}
if err := c.Bind(req); err != nil {
return logError(err)
}
req := &tailcfg.SetDNSRequest{}
if err := binder.BindRequest(c, req); err != nil {
return errors.Wrap(err, 0)
if req.Version < SupportedCapabilityVersion {
return UnsupportedClientVersionError
}
if h.provider == nil {
@@ -42,7 +38,7 @@ func (h *DNSHandlers) SetDNS(c echo.Context) error {
}
if err := h.provider.SetRecord(ctx, req.Type, req.Name, req.Value); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if strings.HasPrefix(req.Name, "_acme-challenge") && req.Type == "TXT" {
@@ -59,16 +55,16 @@ func (h *DNSHandlers) SetDNS(c echo.Context) error {
txtrecords, _ := net.LookupTXT(req.Name)
for _, txt := range txtrecords {
if txt == req.Value {
return binder.WriteResponse(c, http.StatusOK, tailcfg.SetDNSResponse{})
return c.JSON(http.StatusOK, tailcfg.SetDNSResponse{})
}
}
case <-timeout:
return binder.WriteResponse(c, http.StatusOK, tailcfg.SetDNSResponse{})
return c.JSON(http.StatusOK, tailcfg.SetDNSResponse{})
case <-notify:
return nil
}
}
}
return binder.WriteResponse(c, http.StatusOK, tailcfg.SetDNSResponse{})
return c.JSON(http.StatusOK, tailcfg.SetDNSResponse{})
}
-38
View File
@@ -1,12 +1,9 @@
package handlers
import (
"github.com/caddyserver/certmagic"
"github.com/jsiebens/ionscale/internal/config"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"net"
"net/http"
)
func httpsRedirectSkipper(c config.Tls) func(ctx echo.Context) bool {
@@ -23,38 +20,3 @@ func HttpsRedirect(c config.Tls) echo.MiddlewareFunc {
Skipper: httpsRedirectSkipper(c),
})
}
func HttpRedirectHandler(tls config.Tls) echo.HandlerFunc {
if tls.Disable {
return IndexHandler(http.StatusNotFound)
}
if tls.AcmeEnabled {
cfg := certmagic.NewDefault()
if len(cfg.Issuers) > 0 {
if am, ok := cfg.Issuers[0].(*certmagic.ACMEIssuer); ok {
handler := am.HTTPChallengeHandler(http.HandlerFunc(httpRedirectHandler))
return echo.WrapHandler(handler)
}
}
}
return echo.WrapHandler(http.HandlerFunc(httpRedirectHandler))
}
func httpRedirectHandler(w http.ResponseWriter, r *http.Request) {
toURL := "https://"
requestHost := hostOnly(r.Host)
toURL += requestHost
toURL += r.URL.RequestURI()
w.Header().Set("Connection", "close")
http.Redirect(w, r, toURL, http.StatusMovedPermanently)
}
func hostOnly(hostport string) string {
host, _, err := net.SplitHostPort(hostport)
if err != nil {
return hostport
}
return host
}
+70 -60
View File
@@ -2,93 +2,64 @@ package handlers
import (
"fmt"
"github.com/go-jose/go-jose/v3"
"github.com/golang-jwt/jwt/v4"
"github.com/jsiebens/ionscale/internal/bind"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/errors"
"github.com/jsiebens/ionscale/internal/util"
"github.com/labstack/echo/v4"
"gopkg.in/square/go-jose.v2"
"net/http"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"time"
)
func NewIDTokenHandlers(createBinder bind.Factory, config *config.Config, repository domain.Repository) *IDTokenHandlers {
func NewIDTokenHandlers(machineKey key.MachinePublic, config *config.Config, repository domain.Repository) *IDTokenHandlers {
return &IDTokenHandlers{
issuer: config.ServerUrl,
jwksUri: config.CreateUrl("/.well-known/jwks"),
createBinder: createBinder,
repository: repository,
machineKey: machineKey,
issuer: config.PublicUrl.String(),
repository: repository,
}
}
func NewOIDCConfigHandlers(config *config.Config, repository domain.Repository) *OIDCConfigHandlers {
return &OIDCConfigHandlers{
issuer: config.PublicUrl.String(),
jwksUri: config.CreateUrl("/.well-known/jwks"),
repository: repository,
}
}
type IDTokenHandlers struct {
issuer string
jwksUri string
createBinder bind.Factory
repository domain.Repository
}
func (h *IDTokenHandlers) OpenIDConfig(c echo.Context) error {
v := map[string]interface{}{}
v["issuer"] = h.issuer
v["jwks_uri"] = h.jwksUri
v["subject_types_supported"] = []string{"public"}
v["response_types_supported"] = []string{"id_token"}
v["scopes_supported"] = []string{"openid"}
v["id_token_signing_alg_values_supported"] = []string{"RS256"}
v["claims_supported"] = []string{
"sub",
"aud",
"exp",
"iat",
"iss",
"jti",
"nbf",
}
return c.JSON(http.StatusOK, v)
}
func (h *IDTokenHandlers) Jwks(c echo.Context) error {
keySet, err := h.repository.GetJSONWebKeySet(c.Request().Context())
if err != nil {
return errors.Wrap(err, 0)
}
pub := jose.JSONWebKey{Key: keySet.Key.Public(), KeyID: keySet.Key.Id, Algorithm: "RS256", Use: "sig"}
set := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{pub}}
return c.JSON(http.StatusOK, set)
machineKey key.MachinePublic
issuer string
repository domain.Repository
}
func (h *IDTokenHandlers) FetchToken(c echo.Context) error {
ctx := c.Request().Context()
req := &tailcfg.TokenRequest{}
if err := c.Bind(req); err != nil {
return logError(err)
}
if req.CapVersion < SupportedCapabilityVersion {
return UnsupportedClientVersionError
}
keySet, err := h.repository.GetJSONWebKeySet(c.Request().Context())
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
binder, err := h.createBinder(c)
if err != nil {
return errors.Wrap(err, 0)
}
req := &tailcfg.TokenRequest{}
if err := binder.BindRequest(c, req); err != nil {
return errors.Wrap(err, 0)
}
machineKey := binder.Peer().String()
machineKey := h.machineKey.String()
nodeKey := req.NodeKey.String()
var m *domain.Machine
m, err = h.repository.GetMachineByKeys(ctx, machineKey, nodeKey)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if m == nil {
@@ -131,11 +102,50 @@ func (h *IDTokenHandlers) FetchToken(c echo.Context) error {
jwtB64, err := unsignedToken.SignedString(&keySet.Key.PrivateKey)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
resp := tailcfg.TokenResponse{IDToken: jwtB64}
return binder.WriteResponse(c, http.StatusOK, resp)
return c.JSON(http.StatusOK, resp)
}
type OIDCConfigHandlers struct {
issuer string
jwksUri string
repository domain.Repository
}
func (h *OIDCConfigHandlers) OpenIDConfig(c echo.Context) error {
v := map[string]interface{}{}
v["issuer"] = h.issuer
v["jwks_uri"] = h.jwksUri
v["subject_types_supported"] = []string{"public"}
v["response_types_supported"] = []string{"id_token"}
v["scopes_supported"] = []string{"openid"}
v["id_token_signing_alg_values_supported"] = []string{"RS256"}
v["claims_supported"] = []string{
"sub",
"aud",
"exp",
"iat",
"iss",
"jti",
"nbf",
}
return c.JSON(http.StatusOK, v)
}
func (h *OIDCConfigHandlers) Jwks(c echo.Context) error {
keySet, err := h.repository.GetJSONWebKeySet(c.Request().Context())
if err != nil {
return logError(err)
}
pub := jose.JSONWebKey{Key: keySet.Key.Public(), KeyID: keySet.Key.Id, Algorithm: "RS256", Use: "sig"}
set := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{pub}}
return c.JSON(http.StatusOK, set)
}
func (h *IDTokenHandlers) names(m *domain.Machine) (string, string, string) {
+2 -6
View File
@@ -1,17 +1,13 @@
package handlers
import (
tpl "github.com/jsiebens/ionscale/internal/templates"
"github.com/jsiebens/ionscale/internal/version"
"github.com/labstack/echo/v4"
)
func IndexHandler(code int) echo.HandlerFunc {
return func(c echo.Context) error {
info, s := version.GetReleaseInfo()
data := map[string]interface{}{
"Version": info,
"Revision": s,
}
return c.Render(code, "index.html", data)
return c.Render(code, "", tpl.Index(version.GetReleaseInfo()))
}
}
+5 -1
View File
@@ -9,9 +9,13 @@ import (
)
const (
NoiseCapabilityVersion = 28
SupportedCapabilityVersion = 68
NoiseCapabilityVersion = 28
UnsupportedClientVersionMessage = "ionscale only support client version >= 1.48.0, please upgrade your client"
)
var UnsupportedClientVersionError = echo.NewHTTPError(http.StatusBadRequest, UnsupportedClientVersionMessage)
func KeyHandler(keys *config.ServerKeys) echo.HandlerFunc {
legacyPublicKey := keys.LegacyControlKey.Public()
publicKey := keys.ControlKey.Public()
+35 -4
View File
@@ -2,13 +2,12 @@ package handlers
import (
stderrors "errors"
"github.com/jsiebens/ionscale/internal/errors"
"github.com/labstack/echo/v4"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"io"
"net/http"
"tailscale.com/control/controlhttp"
"tailscale.com/control/controlhttp/controlhttpserver"
"tailscale.com/net/netutil"
"tailscale.com/types/key"
)
@@ -28,9 +27,9 @@ func NewNoiseHandlers(controlKey key.MachinePrivate, createPeerHandler CreatePee
}
func (h *NoiseHandlers) Upgrade(c echo.Context) error {
conn, err := controlhttp.AcceptHTTP(c.Request().Context(), c.Response(), c.Request(), h.controlKey)
conn, err := controlhttpserver.AcceptHTTP(c.Request().Context(), c.Response(), c.Request(), h.controlKey, nil)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
handler := h.createPeerHandler(conn.Peer())
@@ -42,3 +41,35 @@ func (h *NoiseHandlers) Upgrade(c echo.Context) error {
}
return nil
}
type JsonBinder struct {
echo.DefaultBinder
}
func (b JsonBinder) Bind(i interface{}, c echo.Context) error {
if err := b.BindPathParams(c, i); err != nil {
return err
}
method := c.Request().Method
if method == http.MethodGet || method == http.MethodDelete || method == http.MethodHead {
if err := b.BindQueryParams(c, i); err != nil {
return err
}
}
if c.Request().ContentLength == 0 {
return nil
}
if err := c.Echo().JSONSerializer.Deserialize(c, i); err != nil {
switch err.(type) {
case *echo.HTTPError:
return err
default:
return echo.NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err)
}
}
return nil
}
+101 -202
View File
@@ -2,27 +2,31 @@ package handlers
import (
"context"
"github.com/jsiebens/ionscale/internal/bind"
"encoding/binary"
"encoding/json"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/core"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/errors"
"github.com/jsiebens/ionscale/internal/mapping"
"github.com/klauspost/compress/zstd"
"github.com/labstack/echo/v4"
"net/http"
"net/netip"
"slices"
"sync"
"tailscale.com/smallzstd"
"tailscale.com/tailcfg"
"tailscale.com/types/opt"
"tailscale.com/types/key"
"tailscale.com/util/dnsname"
"time"
)
func NewPollNetMapHandler(
createBinder bind.Factory,
machineKey key.MachinePublic,
sessionManager core.PollMapSessionManager,
repository domain.Repository) *PollNetMapHandler {
handler := &PollNetMapHandler{
createBinder: createBinder,
machineKey: machineKey,
sessionManager: sessionManager,
repository: repository,
}
@@ -31,89 +35,97 @@ func NewPollNetMapHandler(
}
type PollNetMapHandler struct {
createBinder bind.Factory
machineKey key.MachinePublic
repository domain.Repository
sessionManager core.PollMapSessionManager
}
func (h *PollNetMapHandler) PollNetMap(c echo.Context) error {
ctx := c.Request().Context()
binder, err := h.createBinder(c)
if err != nil {
return errors.Wrap(err, 0)
}
req := &tailcfg.MapRequest{}
if err := binder.BindRequest(c, req); err != nil {
return errors.Wrap(err, 0)
if err := c.Bind(req); err != nil {
return logError(err)
}
machineKey := binder.Peer().String()
if req.Version < SupportedCapabilityVersion {
return UnsupportedClientVersionError
}
machineKey := h.machineKey.String()
nodeKey := req.NodeKey.String()
var m *domain.Machine
m, err = h.repository.GetMachineByKeys(ctx, machineKey, nodeKey)
m, err := h.repository.GetMachineByKeys(ctx, machineKey, nodeKey)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if m == nil {
return echo.NewHTTPError(http.StatusNotFound)
}
if req.ReadOnly {
return h.handleReadOnly(c, binder, m, req)
} else {
return h.handleUpdate(c, binder, m, req)
}
return h.handlePollNetMap(c, m, req)
}
func (h *PollNetMapHandler) handleUpdate(c echo.Context, binder bind.Binder, m *domain.Machine, mapRequest *tailcfg.MapRequest) error {
func (h *PollNetMapHandler) handlePollNetMap(c echo.Context, m *domain.Machine, mapRequest *tailcfg.MapRequest) error {
ctx := c.Request().Context()
now := time.Now().UTC()
m.HostInfo = domain.HostInfo(*mapRequest.Hostinfo)
m.DiscoKey = mapRequest.DiscoKey.String()
m.Endpoints = mapRequest.Endpoints
m.LastSeen = &now
if err := h.repository.SaveMachine(ctx, m); err != nil {
return errors.Wrap(err, 0)
}
tailnetID := m.TailnetID
machineID := m.ID
h.sessionManager.NotifyAll(tailnetID)
mapper := mapping.NewPollNetMapper(mapRequest, m.ID, h.repository, h.sessionManager)
if !mapRequest.Stream {
return c.String(http.StatusOK, "")
response, err := h.createMapResponse(mapper, false, mapRequest.Compress)
if err != nil {
return logError(err)
}
var syncedPeers = make(map[uint64]bool)
var derpMapChecksum = ""
if !mapRequest.Stream {
if !slices.Equal(m.HostInfo.RoutableIPs, mapRequest.Hostinfo.RoutableIPs) {
m.AutoAllowIPs = m.Tailnet.ACLPolicy.Get().FindAutoApprovedIPs(mapRequest.Hostinfo.RoutableIPs, m.Tags, &m.User)
}
response, syncedPeers, derpMapChecksum, err := h.createMapResponse(m, binder, mapRequest, false, make(map[uint64]bool), derpMapChecksum)
if err != nil {
return errors.Wrap(err, 0)
m.HostInfo = domain.HostInfo(*mapRequest.Hostinfo)
m.DiscoKey = mapRequest.DiscoKey.String()
m.Endpoints = mapRequest.Endpoints
m.LastSeen = &now
sanitizeHostname := dnsname.SanitizeHostname(m.HostInfo.Hostname)
if m.UseOSHostname && m.Name != sanitizeHostname {
nameIdx, err := h.repository.GetNextMachineNameIndex(ctx, m.TailnetID, sanitizeHostname)
if err != nil {
return logError(err)
}
m.Name = sanitizeHostname
m.NameIdx = nameIdx
}
if err := h.repository.SaveMachine(ctx, m); err != nil {
return logError(err)
}
h.sessionManager.NotifyAll(tailnetID)
return c.JSONBlob(http.StatusOK, response)
}
updateChan := make(chan *core.Ping, 20)
h.sessionManager.Register(m.TailnetID, m.ID, updateChan)
// Listen to connection close
notify := c.Request().Context().Done()
notify := ctx.Done()
keepAliveResponse, err := h.createKeepAliveResponse(binder, mapRequest)
keepAliveResponse, err := h.createKeepAliveResponse(mapRequest)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
c.Response().WriteHeader(http.StatusOK)
if _, err := c.Response().Write(response); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
c.Response().Flush()
@@ -124,32 +136,34 @@ func (h *PollNetMapHandler) handleUpdate(c echo.Context, binder bind.Binder, m *
defer func() {
connectedDevices.WithLabelValues(m.Tailnet.Name).Dec()
h.sessionManager.Deregister(m.TailnetID, m.ID)
h.sessionManager.Deregister(m.TailnetID, m.ID, updateChan)
keepAliveTicker.Stop()
syncTicker.Stop()
_ = h.repository.SetMachineLastSeen(ctx, machineID)
}()
var latestSync = time.Now()
var latestUpdate = latestSync
var shouldUpdate bool = false
for {
select {
case <-updateChan:
latestUpdate = time.Now()
case _, ok := <-updateChan:
if !ok {
return nil
}
shouldUpdate = true
case <-keepAliveTicker.C:
if mapRequest.KeepAlive {
if _, err := c.Response().Write(keepAliveResponse); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
_ = h.repository.SetMachineLastSeen(ctx, machineID)
c.Response().Flush()
}
case <-syncTicker.C:
if latestSync.Before(latestUpdate) {
if shouldUpdate {
machine, err := h.repository.GetMachine(ctx, machineID)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if machine == nil {
return nil
@@ -158,18 +172,18 @@ func (h *PollNetMapHandler) handleUpdate(c echo.Context, binder bind.Binder, m *
var payload []byte
var payloadErr error
payload, syncedPeers, derpMapChecksum, payloadErr = h.createMapResponse(machine, binder, mapRequest, true, syncedPeers, derpMapChecksum)
payload, payloadErr = h.createMapResponse(mapper, true, mapRequest.Compress)
if payloadErr != nil {
return payloadErr
}
if _, err := c.Response().Write(payload); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
c.Response().Flush()
latestSync = latestUpdate
shouldUpdate = false
}
case <-notify:
return nil
@@ -177,172 +191,57 @@ func (h *PollNetMapHandler) handleUpdate(c echo.Context, binder bind.Binder, m *
}
}
func (h *PollNetMapHandler) handleReadOnly(c echo.Context, binder bind.Binder, m *domain.Machine, request *tailcfg.MapRequest) error {
ctx := c.Request().Context()
m.HostInfo = domain.HostInfo(*request.Hostinfo)
m.DiscoKey = request.DiscoKey.String()
if err := h.repository.SaveMachine(ctx, m); err != nil {
return errors.Wrap(err, 0)
}
response, _, _, err := h.createMapResponse(m, binder, request, false, map[uint64]bool{}, "")
if err != nil {
return errors.Wrap(err, 0)
}
_, err = c.Response().Write(response)
return errors.Wrap(err, 0)
}
func (h *PollNetMapHandler) createKeepAliveResponse(binder bind.Binder, request *tailcfg.MapRequest) ([]byte, error) {
func (h *PollNetMapHandler) createKeepAliveResponse(request *tailcfg.MapRequest) ([]byte, error) {
mapResponse := &tailcfg.MapResponse{
KeepAlive: true,
}
return binder.Marshal(request.Compress, mapResponse)
return h.marshalResponse(request.Compress, mapResponse)
}
func (h *PollNetMapHandler) createMapResponse(m *domain.Machine, binder bind.Binder, request *tailcfg.MapRequest, delta bool, prevSyncedPeerIDs map[uint64]bool, prevDerpMapChecksum string) ([]byte, map[uint64]bool, string, error) {
ctx := context.TODO()
prc := &primaryRoutesCollector{flagged: map[netip.Prefix]bool{}}
tailnet, err := h.repository.GetTailnet(ctx, m.TailnetID)
func (h *PollNetMapHandler) createMapResponse(m *mapping.PollNetMapper, delta bool, compress string) ([]byte, error) {
response, err := m.CreateMapResponse(context.Background(), delta)
if err != nil {
return nil, nil, "", err
return nil, err
}
return h.marshalResponse(compress, response)
}
hostinfo := tailcfg.Hostinfo(m.HostInfo)
node, user, err := mapping.ToNode(m, tailnet, false, true, prc.filter)
func (h *PollNetMapHandler) marshalResponse(compress string, v interface{}) ([]byte, error) {
var payload []byte
marshalled, err := json.Marshal(v)
if err != nil {
return nil, nil, "", err
return nil, err
}
policies := tailnet.ACLPolicy
var users = []tailcfg.UserProfile{*user}
var changedPeers []*tailcfg.Node
var removedPeers []tailcfg.NodeID
candidatePeers, err := h.repository.ListMachinePeers(ctx, m.TailnetID, m.MachineKey)
if err != nil {
return nil, nil, "", err
}
syncedPeerIDs := map[uint64]bool{}
syncedUserIDs := map[tailcfg.UserID]bool{}
for _, peer := range candidatePeers {
if peer.IsExpired() {
continue
}
if policies.IsValidPeer(m, &peer) || policies.IsValidPeer(&peer, m) {
isConnected := h.sessionManager.HasSession(peer.TailnetID, peer.ID)
n, u, err := mapping.ToNode(&peer, tailnet, true, isConnected, prc.filter)
if err != nil {
return nil, nil, "", err
}
changedPeers = append(changedPeers, n)
syncedPeerIDs[peer.ID] = true
delete(prevSyncedPeerIDs, peer.ID)
if _, ok := syncedUserIDs[u.ID]; !ok {
users = append(users, *u)
syncedUserIDs[u.ID] = true
}
}
}
for p, _ := range prevSyncedPeerIDs {
removedPeers = append(removedPeers, tailcfg.NodeID(p))
}
dnsConfig := tailnet.DNSConfig
derpMap, err := m.Tailnet.GetDERPMap(ctx, h.repository)
if err != nil {
return nil, nil, "", err
}
filterRules := policies.BuildFilterRules(candidatePeers, m)
controlTime := time.Now().UTC()
var mapResponse *tailcfg.MapResponse
if !delta {
mapResponse = &tailcfg.MapResponse{
KeepAlive: false,
Node: node,
DNSConfig: mapping.ToDNSConfig(m, &m.Tailnet, &dnsConfig),
PacketFilter: filterRules,
DERPMap: &derpMap.DERPMap,
Domain: domain.SanitizeTailnetName(m.Tailnet.Name),
Peers: changedPeers,
UserProfiles: users,
ControlTime: &controlTime,
CollectServices: optBool(tailnet.ServiceCollectionEnabled),
Debug: &tailcfg.Debug{
DisableLogTail: true,
},
}
if compress == "zstd" {
payload = zstdEncode(marshalled)
} else {
mapResponse = &tailcfg.MapResponse{
Node: node,
DNSConfig: mapping.ToDNSConfig(m, &m.Tailnet, &dnsConfig),
PacketFilter: filterRules,
Domain: domain.SanitizeTailnetName(m.Tailnet.Name),
PeersChanged: changedPeers,
PeersRemoved: removedPeers,
UserProfiles: users,
ControlTime: &controlTime,
CollectServices: optBool(tailnet.ServiceCollectionEnabled),
}
if prevDerpMapChecksum != derpMap.Checksum {
mapResponse.DERPMap = &derpMap.DERPMap
}
payload = marshalled
}
if tailnet.SSHEnabled && hostinfo.TailscaleSSHEnabled() {
mapResponse.SSHPolicy = policies.BuildSSHPolicy(candidatePeers, m)
}
data := make([]byte, 4)
binary.LittleEndian.PutUint32(data, uint32(len(payload)))
data = append(data, payload...)
if request.OmitPeers {
mapResponse.PeersChanged = nil
mapResponse.PeersRemoved = nil
mapResponse.Peers = nil
}
payload, err := binder.Marshal(request.Compress, mapResponse)
return payload, syncedPeerIDs, derpMap.Checksum, nil
return data, nil
}
func optBool(v bool) opt.Bool {
b := opt.Bool("")
b.Set(v)
return b
func zstdEncode(in []byte) []byte {
encoder := zstdEncoderPool.Get().(*zstd.Encoder)
out := encoder.EncodeAll(in, nil)
_ = encoder.Close()
zstdEncoderPool.Put(encoder)
return out
}
type primaryRoutesCollector struct {
flagged map[netip.Prefix]bool
}
func (p *primaryRoutesCollector) filter(m *domain.Machine) []netip.Prefix {
var result = []netip.Prefix{}
for _, r := range m.AllowIPs {
if _, ok := p.flagged[r]; r.Bits() != 0 && !ok {
result = append(result, r)
p.flagged[r] = true
var zstdEncoderPool = &sync.Pool{
New: func() any {
encoder, err := smallzstd.NewEncoder(nil, zstd.WithEncoderLevel(zstd.SpeedFastest))
if err != nil {
panic(err)
}
}
for _, r := range m.AutoAllowIPs {
if _, ok := p.flagged[r]; r.Bits() != 0 && !ok {
result = append(result, r)
p.flagged[r] = true
}
}
return result
return encoder
},
}
+69
View File
@@ -0,0 +1,69 @@
package handlers
import (
"fmt"
"github.com/jsiebens/ionscale/internal/dns"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/labstack/echo/v4"
"net/http"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
func NewQueryFeatureHandlers(machineKey key.MachinePublic, dnsProvider dns.Provider, repository domain.Repository) *QueryFeatureHandlers {
return &QueryFeatureHandlers{
machineKey: machineKey,
dnsProvider: dnsProvider,
repository: repository,
}
}
type QueryFeatureHandlers struct {
machineKey key.MachinePublic
dnsProvider dns.Provider
repository domain.Repository
}
func (h *QueryFeatureHandlers) QueryFeature(c echo.Context) error {
ctx := c.Request().Context()
req := new(tailcfg.QueryFeatureRequest)
if err := c.Bind(req); err != nil {
return logError(err)
}
machineKey := h.machineKey.String()
nodeKey := req.NodeKey.String()
resp := tailcfg.QueryFeatureResponse{Complete: true}
switch req.Feature {
case "serve":
machine, err := h.repository.GetMachineByKeys(ctx, machineKey, nodeKey)
if err != nil {
return err
}
if machine == nil {
return echo.NewHTTPError(http.StatusBadRequest)
}
if h.dnsProvider == nil || !machine.Tailnet.DNSConfig.HttpsCertsEnabled {
resp.Text = fmt.Sprintf(serverMessage, machine.Tailnet.Name)
resp.Complete = false
}
case "funnel":
resp.Text = fmt.Sprintf("Sorry, ionscale has no support for feature '%s'\n", req.Feature)
resp.Complete = false
default:
resp.Text = fmt.Sprintf("Unknown feature request '%s'\n", req.Feature)
resp.Complete = false
}
return c.JSON(http.StatusOK, resp)
}
const serverMessage = `Enabling HTTPS is required to use Serve:
ionscale tailnets set-dns --tailnet %s --https-certs=true --magic-dns
`
+61 -52
View File
@@ -3,28 +3,27 @@ package handlers
import (
"context"
"github.com/jsiebens/ionscale/internal/addr"
"github.com/jsiebens/ionscale/internal/bind"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/core"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/errors"
"github.com/jsiebens/ionscale/internal/mapping"
"github.com/jsiebens/ionscale/internal/util"
"github.com/labstack/echo/v4"
"net/http"
"net/netip"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/util/dnsname"
"time"
)
func NewRegistrationHandlers(
createBinder bind.Factory,
machineKey key.MachinePublic,
config *config.Config,
sessionManager core.PollMapSessionManager,
repository domain.Repository) *RegistrationHandlers {
return &RegistrationHandlers{
createBinder: createBinder,
machineKey: machineKey,
sessionManager: sessionManager,
repository: repository,
config: config,
@@ -32,7 +31,7 @@ func NewRegistrationHandlers(
}
type RegistrationHandlers struct {
createBinder bind.Factory
machineKey key.MachinePublic
repository domain.Repository
sessionManager core.PollMapSessionManager
config *config.Config
@@ -41,30 +40,30 @@ type RegistrationHandlers struct {
func (h *RegistrationHandlers) Register(c echo.Context) error {
ctx := c.Request().Context()
binder, err := h.createBinder(c)
if err != nil {
return errors.Wrap(err, 0)
}
req := &tailcfg.RegisterRequest{}
if err := binder.BindRequest(c, req); err != nil {
return errors.Wrap(err, 0)
if err := c.Bind(req); err != nil {
return logError(err)
}
machineKey := binder.Peer().String()
if req.Version < SupportedCapabilityVersion {
response := tailcfg.RegisterResponse{MachineAuthorized: false, Error: UnsupportedClientVersionMessage}
return c.JSON(http.StatusOK, response)
}
machineKey := h.machineKey.String()
nodeKey := req.NodeKey.String()
var m *domain.Machine
m, err = h.repository.GetMachineByKeys(ctx, machineKey, nodeKey)
m, err := h.repository.GetMachineByKeys(ctx, machineKey, nodeKey)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if m != nil {
if m.IsExpired() {
response := tailcfg.RegisterResponse{NodeKeyExpired: true}
return binder.WriteResponse(c, http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
if !req.Expiry.IsZero() && req.Expiry.Before(time.Now()) {
@@ -72,36 +71,35 @@ func (h *RegistrationHandlers) Register(c echo.Context) error {
if m.Ephemeral {
if _, err := h.repository.DeleteMachine(ctx, m.ID); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
h.sessionManager.NotifyAll(m.TailnetID)
} else {
if err := h.repository.SaveMachine(ctx, m); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
h.sessionManager.NotifyAll(m.TailnetID)
}
response := tailcfg.RegisterResponse{NodeKeyExpired: true}
return binder.WriteResponse(c, http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
sanitizeHostname := dnsname.SanitizeHostname(req.Hostinfo.Hostname)
if m.Name != sanitizeHostname {
if m.UseOSHostname && m.Name != sanitizeHostname {
nameIdx, err := h.repository.GetNextMachineNameIndex(ctx, m.TailnetID, sanitizeHostname)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
m.Name = sanitizeHostname
m.NameIdx = nameIdx
}
advertisedTags := domain.SanitizeTags(req.Hostinfo.RequestTags)
m.Tags = append(m.RegisteredTags, advertisedTags...)
if err := h.repository.SaveMachine(ctx, m); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
tUser, tLogin := mapping.ToUser(m.User)
@@ -112,20 +110,20 @@ func (h *RegistrationHandlers) Register(c echo.Context) error {
Login: tLogin,
}
return binder.WriteResponse(c, http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
return h.authenticateMachine(c, binder, machineKey, req)
return h.authenticateMachine(c, machineKey, req)
}
func (h *RegistrationHandlers) authenticateMachine(c echo.Context, binder bind.Binder, machineKey string, req *tailcfg.RegisterRequest) error {
func (h *RegistrationHandlers) authenticateMachine(c echo.Context, machineKey string, req *tailcfg.RegisterRequest) error {
ctx := c.Request().Context()
if req.Followup != "" {
return h.followup(c, binder, req)
return h.followup(c, req)
}
if req.Auth.AuthKey == "" {
if req.Auth == nil || req.Auth.AuthKey == "" {
key := util.RandStringBytes(8)
authUrl := h.config.CreateUrl("/a/r/%s", key)
@@ -139,49 +137,49 @@ func (h *RegistrationHandlers) authenticateMachine(c echo.Context, binder bind.B
err := h.repository.SaveRegistrationRequest(ctx, &request)
if err != nil {
response := tailcfg.RegisterResponse{MachineAuthorized: false, Error: "something went wrong"}
return binder.WriteResponse(c, http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
response := tailcfg.RegisterResponse{AuthURL: authUrl}
return binder.WriteResponse(c, http.StatusOK, response)
return c.JSON(http.StatusOK, response)
} else {
return h.authenticateMachineWithAuthKey(c, binder, machineKey, req)
return h.authenticateMachineWithAuthKey(c, machineKey, req)
}
}
func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, binder bind.Binder, machineKey string, req *tailcfg.RegisterRequest) error {
func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, machineKey string, req *tailcfg.RegisterRequest) error {
ctx := c.Request().Context()
nodeKey := req.NodeKey.String()
authKey, err := h.repository.LoadAuthKey(ctx, req.Auth.AuthKey)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if authKey == nil {
response := tailcfg.RegisterResponse{MachineAuthorized: false, Error: "invalid auth key"}
return binder.WriteResponse(c, http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
tailnet := authKey.Tailnet
user := authKey.User
if err := tailnet.ACLPolicy.CheckTagOwners(req.Hostinfo.RequestTags, &user); err != nil {
if err := tailnet.ACLPolicy.Get().CheckTagOwners(req.Hostinfo.RequestTags, &user); err != nil {
response := tailcfg.RegisterResponse{MachineAuthorized: false, Error: err.Error()}
return binder.WriteResponse(c, http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
registeredTags := authKey.Tags
advertisedTags := domain.SanitizeTags(req.Hostinfo.RequestTags)
tags := append(registeredTags, advertisedTags...)
autoAllowIPs := tailnet.ACLPolicy.FindAutoApprovedIPs(req.Hostinfo.RoutableIPs, tags, &user)
autoAllowIPs := tailnet.ACLPolicy.Get().FindAutoApprovedIPs(req.Hostinfo.RoutableIPs, tags, &user)
var m *domain.Machine
m, err = h.repository.GetMachineByKey(ctx, tailnet.ID, machineKey)
m, err = h.repository.GetMachineByKeyAndUser(ctx, machineKey, user.ID)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
now := time.Now().UTC()
@@ -190,13 +188,14 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, bi
sanitizeHostname := dnsname.SanitizeHostname(req.Hostinfo.Hostname)
nameIdx, err := h.repository.GetNextMachineNameIndex(ctx, tailnet.ID, sanitizeHostname)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
m = &domain.Machine{
ID: util.NextID(),
Name: sanitizeHostname,
NameIdx: nameIdx,
UseOSHostname: true,
MachineKey: machineKey,
NodeKey: nodeKey,
Ephemeral: authKey.Ephemeral || req.Ephemeral,
@@ -208,8 +207,10 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, bi
KeyExpiryDisabled: len(tags) != 0,
Authorized: !tailnet.MachineAuthorizationEnabled || authKey.PreAuthorized,
User: user,
Tailnet: tailnet,
User: user,
UserID: user.ID,
Tailnet: tailnet,
TailnetID: tailnet.ID,
}
if !req.Expiry.IsZero() {
@@ -218,16 +219,16 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, bi
ipv4, ipv6, err := addr.SelectIP(checkIP(ctx, h.repository.CountMachinesWithIPv4))
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
m.IPv4 = domain.IP{Addr: ipv4}
m.IPv6 = domain.IP{Addr: ipv6}
} else {
sanitizeHostname := dnsname.SanitizeHostname(req.Hostinfo.Hostname)
if m.Name != sanitizeHostname {
if m.UseOSHostname && m.Name != sanitizeHostname {
nameIdx, err := h.repository.GetNextMachineNameIndex(ctx, tailnet.ID, sanitizeHostname)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
m.Name = sanitizeHostname
m.NameIdx = nameIdx
@@ -245,7 +246,7 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, bi
}
if err := h.repository.SaveMachine(ctx, m); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
tUser, tLogin := mapping.ToUser(m.User)
@@ -255,10 +256,10 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, bi
Login: tLogin,
}
return binder.WriteResponse(c, http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
func (h *RegistrationHandlers) followup(c echo.Context, binder bind.Binder, req *tailcfg.RegisterRequest) error {
func (h *RegistrationHandlers) followup(c echo.Context, req *tailcfg.RegisterRequest) error {
// Listen to connection close
ctx := c.Request().Context()
notify := ctx.Done()
@@ -266,7 +267,7 @@ func (h *RegistrationHandlers) followup(c echo.Context, binder bind.Binder, req
defer func() { tick.Stop() }()
machineKey := binder.Peer().String()
machineKey := h.machineKey.String()
for {
select {
@@ -275,10 +276,10 @@ func (h *RegistrationHandlers) followup(c echo.Context, binder bind.Binder, req
if err != nil || m == nil {
response := tailcfg.RegisterResponse{MachineAuthorized: false, Error: "something went wrong"}
return binder.WriteResponse(c, http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
if m != nil && m.IsFinished() {
if m != nil && m.Authenticated {
user, err := h.repository.GetUser(ctx, m.UserID)
if err != nil {
return err
@@ -292,7 +293,15 @@ func (h *RegistrationHandlers) followup(c echo.Context, binder bind.Binder, req
User: u,
Login: l,
}
return binder.WriteResponse(c, http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
if m != nil && len(m.Error) != 0 {
response := tailcfg.RegisterResponse{
MachineAuthorized: len(m.Error) != 0,
Error: m.Error,
}
return c.JSON(http.StatusOK, response)
}
case <-notify:
return nil
+44 -26
View File
@@ -2,49 +2,72 @@ package handlers
import (
"fmt"
"github.com/jsiebens/ionscale/internal/bind"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/errors"
"github.com/jsiebens/ionscale/internal/util"
"github.com/labstack/echo/v4"
"net/http"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"time"
)
func NewSSHActionHandlers(createBinder bind.Factory, config *config.Config, repository domain.Repository) *SSHActionHandlers {
func NewSSHActionHandlers(machineKey key.MachinePublic, config *config.Config, repository domain.Repository) *SSHActionHandlers {
return &SSHActionHandlers{
createBinder: createBinder,
repository: repository,
config: config,
machineKey: machineKey,
repository: repository,
config: config,
}
}
type SSHActionHandlers struct {
createBinder bind.Factory
repository domain.Repository
config *config.Config
machineKey key.MachinePublic
repository domain.Repository
config *config.Config
}
type sshActionRequestData struct {
SrcMachineID uint64 `param:"src_machine_id"`
DstMachineID uint64 `param:"dst_machine_id"`
CheckPeriod string `param:"check_period"`
}
func (h *SSHActionHandlers) StartAuth(c echo.Context) error {
ctx := c.Request().Context()
binder, err := h.createBinder(c)
if err != nil {
return errors.Wrap(err, 0)
}
data := new(sshActionRequestData)
if err = c.Bind(data); err != nil {
return errors.Wrap(err, 0)
if err := c.Bind(data); err != nil {
return logError(err)
}
if data.CheckPeriod != "" && data.CheckPeriod != "always" {
checkPeriod, err := time.ParseDuration(data.CheckPeriod)
if err != nil {
_ = logError(err)
goto check
}
machine, err := h.repository.GetMachine(ctx, data.SrcMachineID)
if err != nil {
return logError(err)
}
if machine.User.Account != nil && machine.User.LastAuthenticated != nil {
sinceLastAuthentication := time.Since(*machine.User.LastAuthenticated)
if sinceLastAuthentication < checkPeriod {
resp := &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
}
return c.JSON(http.StatusOK, resp)
}
}
}
check:
key := util.RandStringBytes(8)
request := &domain.SSHActionRequest{
Key: key,
@@ -56,7 +79,7 @@ func (h *SSHActionHandlers) StartAuth(c echo.Context) error {
authUrl := h.config.CreateUrl("/a/s/%s", key)
if err := h.repository.SaveSSHActionRequest(ctx, request); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
resp := &tailcfg.SSHAction{
@@ -64,7 +87,7 @@ func (h *SSHActionHandlers) StartAuth(c echo.Context) error {
HoldAndDelegate: fmt.Sprintf("https://unused/machine/ssh/action/check/%s", key),
}
return binder.WriteResponse(c, http.StatusOK, resp)
return c.JSON(http.StatusOK, resp)
}
func (h *SSHActionHandlers) CheckAuth(c echo.Context) error {
@@ -72,11 +95,6 @@ func (h *SSHActionHandlers) CheckAuth(c echo.Context) error {
ctx := c.Request().Context()
notify := ctx.Done()
binder, err := h.createBinder(c)
if err != nil {
return errors.Wrap(err, 0)
}
tick := time.NewTicker(2 * time.Second)
defer func() { tick.Stop() }()
@@ -89,7 +107,7 @@ func (h *SSHActionHandlers) CheckAuth(c echo.Context) error {
m, err := h.repository.GetSSHActionRequest(ctx, key)
if err != nil || m == nil {
return binder.WriteResponse(c, http.StatusOK, &tailcfg.SSHAction{Reject: true})
return c.JSON(http.StatusOK, &tailcfg.SSHAction{Reject: true})
}
if m.Action == "accept" {
@@ -99,13 +117,13 @@ func (h *SSHActionHandlers) CheckAuth(c echo.Context) error {
AllowLocalPortForwarding: true,
}
_ = h.repository.DeleteSSHActionRequest(ctx, key)
return binder.WriteResponse(c, http.StatusOK, action)
return c.JSON(http.StatusOK, action)
}
if m.Action == "reject" {
action := &tailcfg.SSHAction{Reject: true}
_ = h.repository.DeleteSSHActionRequest(ctx, key)
return binder.WriteResponse(c, http.StatusOK, action)
return c.JSON(http.StatusOK, action)
}
case <-notify:
return nil
+49
View File
@@ -0,0 +1,49 @@
package handlers
import (
"github.com/jsiebens/ionscale/internal/domain"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"net/http"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
func NewUpdateHealthHandlers(machineKey key.MachinePublic, repository domain.Repository) *UpdateFeatureHandlers {
return &UpdateFeatureHandlers{machineKey: machineKey, repository: repository}
}
type UpdateFeatureHandlers struct {
machineKey key.MachinePublic
repository domain.Repository
}
func (h *UpdateFeatureHandlers) UpdateHealth(c echo.Context) error {
ctx := c.Request().Context()
req := new(tailcfg.HealthChangeRequest)
if err := c.Bind(req); err != nil {
return logError(err)
}
machineKey := h.machineKey.String()
nodeKey := req.NodeKey.String()
machine, err := h.repository.GetMachineByKeys(ctx, machineKey, nodeKey)
if err != nil {
return err
}
if machine == nil {
return echo.NewHTTPError(http.StatusBadRequest)
}
zap.L().Debug("Health checks updated",
zap.Uint64("tailnet", machine.TailnetID),
zap.Uint64("machine", machine.ID),
zap.String("subsystem", req.Subsys),
zap.String("err", req.Error),
)
return c.String(http.StatusOK, "OK")
}
+6
View File
@@ -3,6 +3,7 @@ package handlers
import (
"github.com/jsiebens/ionscale/internal/version"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"net/http"
)
@@ -14,3 +15,8 @@ func Version(c echo.Context) error {
}
return c.JSON(http.StatusOK, resp)
}
func logError(err error) error {
zap.L().WithOptions(zap.AddCallerSkip(1)).Error("error processing request", zap.Error(err))
return err
}
+88 -70
View File
@@ -1,12 +1,12 @@
package mapping
import (
"encoding/json"
"fmt"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/util"
"net/netip"
"slices"
"strconv"
"tailscale.com/tailcfg"
"tailscale.com/types/dnstype"
@@ -14,39 +14,26 @@ import (
"time"
)
func CopyViaJson[F any, T any](f F, t T) error {
raw, err := json.Marshal(f)
if err != nil {
return err
}
if err := json.Unmarshal(raw, t); err != nil {
return err
}
return nil
}
func ToDNSConfig(m *domain.Machine, tailnet *domain.Tailnet, c *domain.DNSConfig) *tailcfg.DNSConfig {
certsEnabled := c.HttpsCertsEnabled && config.DNSProviderConfigured()
sanitizeTailnetName := domain.SanitizeTailnetName(tailnet.Name)
tailnetDomain := fmt.Sprintf("%s.%s", sanitizeTailnetName, config.MagicDNSSuffix())
resolvers := []*dnstype.Resolver{}
resolvers := make([]*dnstype.Resolver, 0)
for _, r := range c.Nameservers {
resolver := &dnstype.Resolver{
Addr: r,
}
resolvers = append(resolvers, resolver)
resolvers = append(resolvers, &dnstype.Resolver{Addr: r})
}
dnsConfig := &tailcfg.DNSConfig{}
var routes = make(map[string][]*dnstype.Resolver)
var domains []string
var certDomains []string
if c.MagicDNS {
routes[tailnetDomain] = nil
domains = append(domains, tailnetDomain)
dnsConfig.Proxied = true
@@ -62,44 +49,29 @@ func ToDNSConfig(m *domain.Machine, tailnet *domain.Tailnet, c *domain.DNSConfig
}
if len(c.Routes) != 0 || certsEnabled {
routes := make(map[string][]*dnstype.Resolver)
for r, s := range c.Routes {
routeResolver := []*dnstype.Resolver{}
routeResolver := make([]*dnstype.Resolver, 0)
for _, addr := range s {
resolver := &dnstype.Resolver{Addr: addr}
routeResolver = append(routeResolver, resolver)
routeResolver = append(routeResolver, &dnstype.Resolver{Addr: addr})
}
routes[r] = routeResolver
domains = append(domains, r)
}
dnsConfig.Routes = routes
}
dnsConfig.Domains = domains
dnsConfig.Domains = append(domains, c.SearchDomains...)
dnsConfig.CertDomains = certDomains
dnsConfig.ExitNodeFilteredSet = []string{
fmt.Sprintf(".%s", config.MagicDNSSuffix()),
}
return dnsConfig
}
func ToNode(m *domain.Machine, tailnet *domain.Tailnet, peer bool, connected bool, routeFilter func(m *domain.Machine) []netip.Prefix) (*tailcfg.Node, *tailcfg.UserProfile, error) {
role := tailnet.IAMPolicy.GetRole(m.User)
var capabilities []string
if !peer {
if !m.HasTags() && role == domain.UserRoleAdmin {
capabilities = append(capabilities, tailcfg.CapabilityAdmin)
}
if tailnet.FileSharingEnabled {
capabilities = append(capabilities, tailcfg.CapabilityFileSharing)
}
if tailnet.SSHEnabled {
capabilities = append(capabilities, tailcfg.CapabilitySSH)
}
}
func ToNode(capVer tailcfg.CapabilityVersion, m *domain.Machine, tailnet *domain.Tailnet, taggedDevicesUser *domain.User, peer bool, connected bool, routeFilter func(m *domain.Machine) []netip.Prefix) (*tailcfg.Node, *tailcfg.UserProfile, error) {
role := tailnet.IAMPolicy.Get().GetRole(m.User)
nKey, err := util.ParseNodePublicKey(m.NodeKey)
if err != nil {
@@ -148,11 +120,18 @@ func ToNode(m *domain.Machine, tailnet *domain.Tailnet, peer bool, connected boo
allowedIPs = append(allowedIPs, routeFilter(m)...)
}
var derp string
if m.IsAllowedExitNode() {
allowedIPs = append(allowedIPs, netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"))
}
var derp int
var legacyDerp string
if hostinfo.NetInfo != nil {
derp = fmt.Sprintf("127.3.3.40:%d", hostinfo.NetInfo.PreferredDERP)
derp = hostinfo.NetInfo.PreferredDERP
legacyDerp = fmt.Sprintf("127.3.3.40:%d", hostinfo.NetInfo.PreferredDERP)
} else {
derp = "127.3.3.40:0"
derp = 0
legacyDerp = "127.3.3.40:0"
}
var name = m.CompleteName()
@@ -160,32 +139,74 @@ func ToNode(m *domain.Machine, tailnet *domain.Tailnet, peer bool, connected boo
sanitizedTailnetName := domain.SanitizeTailnetName(m.Tailnet.Name)
hostInfo := tailcfg.Hostinfo{
OS: hostinfo.OS,
Hostname: hostinfo.Hostname,
Services: filterServices(hostinfo.Services),
OS: hostinfo.OS,
Hostname: hostinfo.Hostname,
Services: filterServices(hostinfo.Services),
SSH_HostKeys: hostinfo.SSH_HostKeys,
}
n := tailcfg.Node{
ID: tailcfg.NodeID(m.ID),
StableID: tailcfg.StableNodeID(strconv.FormatUint(m.ID, 10)),
Name: fmt.Sprintf("%s.%s.%s.", name, sanitizedTailnetName, config.MagicDNSSuffix()),
Key: *nKey,
Machine: *mKey,
DiscoKey: discoKey,
Addresses: addrs,
AllowedIPs: allowedIPs,
Endpoints: endpoints,
DERP: derp,
ID: tailcfg.NodeID(m.ID),
StableID: tailcfg.StableNodeID(strconv.FormatUint(m.ID, 10)),
Name: fmt.Sprintf("%s.%s.%s.", name, sanitizedTailnetName, config.MagicDNSSuffix()),
Key: *nKey,
Machine: *mKey,
DiscoKey: discoKey,
Addresses: addrs,
AllowedIPs: allowedIPs,
Endpoints: endpoints,
HomeDERP: derp,
LegacyDERPString: legacyDerp,
Hostinfo: hostInfo.View(),
Capabilities: capabilities,
Created: m.CreatedAt.UTC(),
Hostinfo: hostInfo.View(),
Created: m.CreatedAt.UTC(),
MachineAuthorized: m.Authorized,
User: tailcfg.UserID(m.UserID),
}
if !peer {
var capabilities []tailcfg.NodeCapability
capMap := make(tailcfg.NodeCapMap)
for _, c := range tailnet.ACLPolicy.Get().NodeCapabilities(m) {
capabilities = append(capabilities, c)
capMap[c] = []tailcfg.RawMessage{}
}
if !m.HasTags() && role == domain.UserRoleAdmin {
capabilities = append(capabilities, tailcfg.CapabilityAdmin)
capMap[tailcfg.CapabilityAdmin] = []tailcfg.RawMessage{}
}
if tailnet.FileSharingEnabled {
capabilities = append(capabilities, tailcfg.CapabilityFileSharing)
capMap[tailcfg.CapabilityFileSharing] = []tailcfg.RawMessage{}
}
if tailnet.SSHEnabled {
capabilities = append(capabilities, tailcfg.CapabilitySSH)
capMap[tailcfg.CapabilitySSH] = []tailcfg.RawMessage{}
}
if tailnet.DNSConfig.HttpsCertsEnabled {
capabilities = append(capabilities, tailcfg.CapabilityHTTPS)
capMap[tailcfg.CapabilityHTTPS] = []tailcfg.RawMessage{}
}
// ionscale has no support for Funnel yet, so remove Funnel attribute if set via ACL policy
{
slices.DeleteFunc(capabilities, func(c tailcfg.NodeCapability) bool { return c == tailcfg.NodeAttrFunnel })
delete(capMap, tailcfg.NodeAttrFunnel)
}
if capVer >= 74 {
n.CapMap = capMap
} else {
n.Capabilities = capabilities
}
}
if !m.ExpiresAt.IsZero() {
e := m.ExpiresAt.UTC()
n.KeyExpiry = e
@@ -203,9 +224,10 @@ func ToNode(m *domain.Machine, tailnet *domain.Tailnet, peer bool, connected boo
var user = ToUserProfile(m.User)
if m.HasTags() {
n.User = tailcfg.UserID(m.ID)
n.User = tailcfg.UserID(taggedDevicesUser.ID)
n.Tags = m.Tags
user = tailcfg.UserProfile{
ID: tailcfg.UserID(m.ID),
ID: tailcfg.UserID(taggedDevicesUser.ID),
LoginName: "tagged-devices",
DisplayName: "Tagged Devices",
}
@@ -226,16 +248,12 @@ func ToUserProfile(u domain.User) tailcfg.UserProfile {
func ToUser(u domain.User) (tailcfg.User, tailcfg.Login) {
user := tailcfg.User{
ID: tailcfg.UserID(u.ID),
LoginName: u.Name,
DisplayName: u.Name,
Logins: []tailcfg.LoginID{tailcfg.LoginID(u.ID)},
Domain: u.Tailnet.Name,
}
login := tailcfg.Login{
ID: tailcfg.LoginID(u.ID),
LoginName: u.Name,
DisplayName: u.Name,
Domain: u.Tailnet.Name,
}
return user, login
}
+197
View File
@@ -0,0 +1,197 @@
package mapping
import (
"context"
"github.com/jsiebens/ionscale/internal/core"
"github.com/jsiebens/ionscale/internal/domain"
"net/netip"
"sync"
"tailscale.com/tailcfg"
"tailscale.com/types/opt"
"time"
)
// MapResponse is a custom tailcfg.MapResponse
// for marshalling non-nil zero-length slices (meaning explicitly now empty)
// see tailcfg.MapResponse documentation
type MapResponse struct {
tailcfg.MapResponse
PacketFilter []tailcfg.FilterRule
}
func NewPollNetMapper(req *tailcfg.MapRequest, machineID uint64, repository domain.Repository, sessionManager core.PollMapSessionManager) *PollNetMapper {
return &PollNetMapper{
req: req,
machineID: machineID,
prevSyncedPeerIDs: make(map[uint64]bool),
prevDerpMapChecksum: "",
repository: repository,
sessionManager: sessionManager,
}
}
type PollNetMapper struct {
sync.Mutex
req *tailcfg.MapRequest
machineID uint64
prevSyncedPeerIDs map[uint64]bool
prevDerpMapChecksum string
repository domain.Repository
sessionManager core.PollMapSessionManager
}
func (h *PollNetMapper) CreateMapResponse(ctx context.Context, delta bool) (*MapResponse, error) {
h.Lock()
defer h.Unlock()
m, err := h.repository.GetMachine(ctx, h.machineID)
if err != nil {
return nil, err
}
hostinfo := tailcfg.Hostinfo(m.HostInfo)
tailnet := m.Tailnet
policies := tailnet.ACLPolicy.Get()
dnsConfig := tailnet.DNSConfig
serviceUser, _, err := h.repository.GetOrCreateServiceUser(ctx, &tailnet)
if err != nil {
return nil, err
}
derpMap, err := m.Tailnet.GetDERPMap(ctx, domain.GetDefaultDERPMap())
if err != nil {
return nil, err
}
prc := &primaryRoutesCollector{flagged: map[netip.Prefix]bool{}}
node, user, err := ToNode(h.req.Version, m, &tailnet, serviceUser, false, true, prc.filter)
if err != nil {
return nil, err
}
var users = []tailcfg.UserProfile{*user}
var changedPeers []*tailcfg.Node
var removedPeers []tailcfg.NodeID
var filterRules = make([]tailcfg.FilterRule, 0)
var sshPolicy *tailcfg.SSHPolicy
syncedPeerIDs := map[uint64]bool{}
if !h.req.OmitPeers {
candidatePeers, err := h.repository.ListMachinePeers(ctx, m.TailnetID, m.ID)
if err != nil {
return nil, err
}
syncedUserIDs := map[tailcfg.UserID]bool{user.ID: true}
for _, peer := range candidatePeers {
if policies.IsValidPeer(m, &peer) || policies.IsValidPeer(&peer, m) {
isConnected := h.sessionManager.HasSession(peer.TailnetID, peer.ID)
n, u, err := ToNode(h.req.Version, &peer, &tailnet, serviceUser, true, isConnected, prc.filter)
if err != nil {
return nil, err
}
changedPeers = append(changedPeers, n)
syncedPeerIDs[peer.ID] = true
delete(h.prevSyncedPeerIDs, peer.ID)
if _, ok := syncedUserIDs[u.ID]; !ok {
users = append(users, *u)
syncedUserIDs[u.ID] = true
}
}
}
for p, _ := range h.prevSyncedPeerIDs {
removedPeers = append(removedPeers, tailcfg.NodeID(p))
}
filterRules = policies.BuildFilterRules(candidatePeers, m)
if tailnet.SSHEnabled && hostinfo.TailscaleSSHEnabled() {
sshPolicy = policies.BuildSSHPolicy(candidatePeers, m)
}
}
controlTime := time.Now().UTC()
var mapResponse tailcfg.MapResponse
if !delta {
mapResponse = tailcfg.MapResponse{
KeepAlive: false,
Node: node,
DNSConfig: ToDNSConfig(m, &m.Tailnet, &dnsConfig),
PacketFilter: filterRules,
SSHPolicy: sshPolicy,
DERPMap: &derpMap.DERPMap,
Domain: domain.SanitizeTailnetName(m.Tailnet.Name),
Peers: changedPeers,
UserProfiles: users,
ControlTime: &controlTime,
CollectServices: optBool(tailnet.ServiceCollectionEnabled),
Debug: &tailcfg.Debug{
DisableLogTail: true,
},
}
} else {
mapResponse = tailcfg.MapResponse{
Node: node,
DNSConfig: ToDNSConfig(m, &m.Tailnet, &dnsConfig),
PacketFilter: filterRules,
SSHPolicy: sshPolicy,
Domain: domain.SanitizeTailnetName(m.Tailnet.Name),
PeersChanged: changedPeers,
PeersRemoved: removedPeers,
UserProfiles: users,
ControlTime: &controlTime,
CollectServices: optBool(tailnet.ServiceCollectionEnabled),
}
if h.prevDerpMapChecksum != derpMap.Checksum {
mapResponse.DERPMap = &derpMap.DERPMap
}
}
if h.req.OmitPeers {
mapResponse.PeersChanged = nil
mapResponse.PeersRemoved = nil
mapResponse.Peers = nil
}
h.prevSyncedPeerIDs = syncedPeerIDs
h.prevDerpMapChecksum = derpMap.Checksum
return &MapResponse{MapResponse: mapResponse, PacketFilter: filterRules}, nil
}
type primaryRoutesCollector struct {
flagged map[netip.Prefix]bool
}
func (p *primaryRoutesCollector) filter(m *domain.Machine) []netip.Prefix {
var result []netip.Prefix
for _, r := range m.AllowIPs {
if _, ok := p.flagged[r]; r.Bits() != 0 && !ok {
result = append(result, r)
p.flagged[r] = true
}
}
for _, r := range m.AutoAllowIPs {
if _, ok := p.flagged[r]; r.Bits() != 0 && !ok {
result = append(result, r)
p.flagged[r] = true
}
}
return result
}
func optBool(v bool) opt.Bool {
b := opt.Bool("")
b.Set(v)
return b
}
+12 -44
View File
@@ -2,54 +2,34 @@ package server
import (
"fmt"
"github.com/hashicorp/go-hclog"
"github.com/jsiebens/ionscale/internal/errors"
"github.com/labstack/echo-contrib/prometheus"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"net/http"
"strings"
"time"
)
func EchoErrorHandler(logger hclog.Logger) echo.MiddlewareFunc {
func EchoErrorHandler() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
request := c.Request()
if err := next(c); err != nil {
switch t := err.(type) {
case *echo.HTTPError:
return err
case *errors.Error:
logger.Error("error processing request",
"err", t.Cause,
"location", t.Location,
"http.method", request.Method,
"http.uri", request.RequestURI,
)
default:
logger.Error("error processing request",
"err", err,
"http.method", request.Method,
"http.uri", request.RequestURI,
)
}
err := next(c)
if strings.HasPrefix(request.RequestURI, "/a/") {
return c.Render(http.StatusInternalServerError, "error.html", nil)
}
if err != nil && strings.HasPrefix(request.RequestURI, "/a/") {
return c.Render(http.StatusInternalServerError, "error.html", nil)
}
return nil
return err
}
}
}
func EchoLogger(logger hclog.Logger) echo.MiddlewareFunc {
httpLogger := logger.Named("http")
func EchoLogger(logger *zap.Logger) echo.MiddlewareFunc {
httpLogger := logger.Sugar()
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) (err error) {
if !httpLogger.IsTrace() {
if !httpLogger.Level().Enabled(zap.DebugLevel) {
return next(c)
}
@@ -60,7 +40,7 @@ func EchoLogger(logger hclog.Logger) echo.MiddlewareFunc {
c.Error(err)
}
httpLogger.Trace("finished server http call",
httpLogger.Debugw("finished server http call",
"http.code", response.Status,
"http.method", request.Method,
"http.uri", request.RequestURI,
@@ -72,7 +52,7 @@ func EchoLogger(logger hclog.Logger) echo.MiddlewareFunc {
}
}
func EchoRecover(logger hclog.Logger) echo.MiddlewareFunc {
func EchoRecover() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
apply := func() (topErr error) {
@@ -82,6 +62,7 @@ func EchoRecover(logger hclog.Logger) echo.MiddlewareFunc {
if !ok {
err = fmt.Errorf("%v", r)
}
zap.L().Error("panic when processing request", zap.Error(err))
topErr = err
}
}()
@@ -91,16 +72,3 @@ func EchoRecover(logger hclog.Logger) echo.MiddlewareFunc {
}
}
}
func ErrorRedirect() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Set("redirect_on_error", true)
return next(c)
}
}
}
func EchoMetrics(p *prometheus.Prometheus) echo.MiddlewareFunc {
return p.HandlerFunc
}
+2 -3
View File
@@ -2,7 +2,6 @@ package server
import (
"github.com/bufbuild/connect-go"
"github.com/hashicorp/go-hclog"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/key"
"github.com/jsiebens/ionscale/internal/service"
@@ -10,7 +9,7 @@ import (
"net/http"
)
func NewRpcHandler(systemAdminKey *key.ServerPrivate, repository domain.Repository, logger hclog.Logger, handler apiconnect.IonscaleServiceHandler) (string, http.Handler) {
interceptors := connect.WithInterceptors(service.NewErrorInterceptor(logger), service.AuthenticationInterceptor(systemAdminKey, repository))
func NewRpcHandler(systemAdminKey *key.ServerPrivate, repository domain.Repository, handler apiconnect.IonscaleServiceHandler) (string, http.Handler) {
interceptors := connect.WithInterceptors(service.NewErrorInterceptor(), service.AuthenticationInterceptor(systemAdminKey, repository))
return apiconnect.NewIonscaleServiceHandler(handler, interceptors)
}
+223 -132
View File
@@ -3,35 +3,44 @@ package server
import (
"context"
"crypto/tls"
"errors"
"fmt"
"github.com/caddyserver/certmagic"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-plugin"
"github.com/jsiebens/ionscale/internal/auth"
"github.com/jsiebens/ionscale/internal/bind"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/core"
"github.com/jsiebens/ionscale/internal/database"
"github.com/jsiebens/ionscale/internal/derp"
"github.com/jsiebens/ionscale/internal/dns"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/handlers"
"github.com/jsiebens/ionscale/internal/service"
"github.com/jsiebens/ionscale/internal/stunserver"
"github.com/jsiebens/ionscale/internal/templates"
echo_prometheus "github.com/labstack/echo-contrib/prometheus"
"github.com/jsiebens/ionscale/internal/util"
"github.com/labstack/echo-contrib/echoprometheus"
"github.com/labstack/echo-contrib/pprof"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
certmagicsql "github.com/travisjeffery/certmagic-sqlstorage"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"golang.org/x/sync/errgroup"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"os/signal"
"syscall"
"tailscale.com/types/key"
"time"
)
func Start(c *config.Config) error {
func Start(ctx context.Context, c *config.Config) error {
ctx = contextWithSigterm(ctx)
logger, err := setupLogging(c.Logging)
if err != nil {
return err
@@ -39,89 +48,105 @@ func Start(c *config.Config) error {
logger.Info("Starting ionscale server")
repository, err := database.OpenDB(&c.Database, logger)
if err != nil {
logError := func(err error) error {
if err != nil {
zap.L().WithOptions(zap.AddCallerSkip(1)).Error("Unable to start server", zap.Error(err))
}
return err
}
util.EnsureIDProvider()
derpMap, err := derp.LoadDERPSources(c)
if err != nil {
logger.Warn("not all derp sources are read successfully", zap.Error(err))
}
domain.SetDefaultDERPMap(derpMap)
httpLogger := logger.Named("http")
dbLogger := logger.Named("db")
db, repository, err := database.OpenDB(&c.Database, dbLogger)
if err != nil {
return logError(err)
}
sessionManager := core.NewPollMapSessionManager()
defaultControlKeys, err := repository.GetControlKeys(context.Background())
defaultControlKeys, err := repository.GetControlKeys(ctx)
if err != nil {
return err
return logError(err)
}
serverKey, err := c.ReadServerKeys(defaultControlKeys)
if err != nil {
return err
return logError(err)
}
core.StartReaper(repository, sessionManager)
serverUrl, err := url.Parse(c.ServerUrl)
if err != nil {
return err
}
core.StartWorker(repository, sessionManager)
// prepare CertMagic
if c.Tls.AcmeEnabled {
certmagic.DefaultACME.Agreed = true
certmagic.DefaultACME.Email = c.Tls.AcmeEmail
certmagic.DefaultACME.CA = c.Tls.AcmeCA
if c.Tls.AcmePath != "" {
certmagic.Default.Storage = &certmagic.FileStorage{Path: c.Tls.AcmePath}
}
cfg := certmagic.NewDefault()
if err := cfg.ManageAsync(context.Background(), []string{serverUrl.Host}); err != nil {
storage, err := certmagicsql.NewStorage(ctx, db, certmagicsql.Options{})
if err != nil {
return err
}
c.HttpListenAddr = fmt.Sprintf(":%d", certmagic.HTTPPort)
c.HttpsListenAddr = fmt.Sprintf(":%d", certmagic.HTTPSPort)
certmagicLogger := logger.Named("certmagic")
certmagic.DefaultACME.Agreed = true
certmagic.DefaultACME.Email = c.Tls.AcmeEmail
certmagic.DefaultACME.CA = c.Tls.AcmeCA
certmagic.DefaultACME.Logger = certmagicLogger
certmagic.Default.Logger = certmagicLogger
certmagic.Default.Storage = storage
cfg := certmagic.NewDefault()
if err := cfg.ManageAsync(ctx, []string{c.PublicUrl.Hostname()}); err != nil {
return logError(err)
}
}
authProvider, systemIAMPolicy, err := setupAuthProvider(c.Auth)
if err != nil {
return fmt.Errorf("error configuring OIDC provider: %v", err)
return logError(fmt.Errorf("error configuring OIDC provider: %v", err))
}
dnsProvider, err := dns.NewProvider(c.DNS)
if err != nil {
return err
return logError(err)
}
p := echo_prometheus.NewPrometheus("http", nil)
metricsHandler := echo.New()
p.SetMetricsPath(metricsHandler)
promMiddleware := echoprometheus.NewMiddleware("http")
createPeerHandler := func(machinePublicKey key.MachinePublic) http.Handler {
binder := bind.DefaultBinder(machinePublicKey)
registrationHandlers := handlers.NewRegistrationHandlers(binder, c, sessionManager, repository)
pollNetMapHandler := handlers.NewPollNetMapHandler(binder, sessionManager, repository)
dnsHandlers := handlers.NewDNSHandlers(binder, dnsProvider)
idTokenHandlers := handlers.NewIDTokenHandlers(binder, c, repository)
sshActionHandlers := handlers.NewSSHActionHandlers(binder, c, repository)
registrationHandlers := handlers.NewRegistrationHandlers(machinePublicKey, c, sessionManager, repository)
pollNetMapHandler := handlers.NewPollNetMapHandler(machinePublicKey, sessionManager, repository)
dnsHandlers := handlers.NewDNSHandlers(machinePublicKey, dnsProvider)
idTokenHandlers := handlers.NewIDTokenHandlers(machinePublicKey, c, repository)
sshActionHandlers := handlers.NewSSHActionHandlers(machinePublicKey, c, repository)
queryFeatureHandlers := handlers.NewQueryFeatureHandlers(machinePublicKey, dnsProvider, repository)
updateHealthHandlers := handlers.NewUpdateHealthHandlers(machinePublicKey, repository)
e := echo.New()
e.Use(EchoMetrics(p), EchoLogger(logger), EchoErrorHandler(logger), EchoRecover(logger))
e.Binder = handlers.JsonBinder{}
e.Use(promMiddleware, EchoLogger(httpLogger), EchoErrorHandler(), EchoRecover())
e.POST("/machine/register", registrationHandlers.Register)
e.POST("/machine/map", pollNetMapHandler.PollNetMap)
e.POST("/machine/set-dns", dnsHandlers.SetDNS)
e.POST("/machine/id-token", idTokenHandlers.FetchToken)
e.GET("/machine/ssh/action/:src_machine_id/to/:dst_machine_id", sshActionHandlers.StartAuth)
e.GET("/machine/ssh/action/:src_machine_id/to/:dst_machine_id/:check_period", sshActionHandlers.StartAuth)
e.GET("/machine/ssh/action/check/:key", sshActionHandlers.CheckAuth)
e.POST("/machine/feature/query", queryFeatureHandlers.QueryFeature)
e.POST("/machine/update-health", updateHealthHandlers.UpdateHealth)
return e
}
noiseHandlers := handlers.NewNoiseHandlers(serverKey.ControlKey, createPeerHandler)
registrationHandlers := handlers.NewRegistrationHandlers(bind.BoxBinder(serverKey.LegacyControlKey), c, sessionManager, repository)
pollNetMapHandler := handlers.NewPollNetMapHandler(bind.BoxBinder(serverKey.LegacyControlKey), sessionManager, repository)
dnsHandlers := handlers.NewDNSHandlers(bind.BoxBinder(serverKey.LegacyControlKey), dnsProvider)
idTokenHandlers := handlers.NewIDTokenHandlers(bind.BoxBinder(serverKey.LegacyControlKey), c, repository)
oidcConfigHandlers := handlers.NewOIDCConfigHandlers(c, repository)
authenticationHandlers := handlers.NewAuthenticationHandlers(
c,
authProvider,
@@ -129,82 +154,126 @@ func Start(c *config.Config) error {
repository,
)
rpcService := service.NewService(c, authProvider, repository, sessionManager)
rpcPath, rpcHandler := NewRpcHandler(serverKey.SystemAdminKey, repository, logger, rpcService)
rpcService := service.NewService(c, authProvider, dnsProvider, repository, sessionManager)
rpcPath, rpcHandler := NewRpcHandler(serverKey.SystemAdminKey, repository, rpcService)
nonTlsAppHandler := echo.New()
nonTlsAppHandler.Use(EchoMetrics(p), EchoLogger(logger), EchoErrorHandler(logger), EchoRecover(logger))
nonTlsAppHandler.POST("/ts2021", noiseHandlers.Upgrade)
nonTlsAppHandler.Any("/*", handlers.HttpRedirectHandler(c.Tls))
metricsMux := echo.New()
metricsMux.GET("/metrics", echoprometheus.NewHandler())
pprof.Register(metricsMux)
tlsAppHandler := echo.New()
tlsAppHandler.Renderer = templates.NewTemplates()
tlsAppHandler.Pre(handlers.HttpsRedirect(c.Tls))
tlsAppHandler.Use(EchoMetrics(p), EchoLogger(logger), EchoErrorHandler(logger), EchoRecover(logger))
webMux := echo.New()
webMux.Renderer = &templates.Renderer{}
webMux.Pre(handlers.HttpsRedirect(c.Tls))
webMux.Use(promMiddleware, EchoLogger(httpLogger), EchoErrorHandler(), EchoRecover())
tlsAppHandler.Any("/*", handlers.IndexHandler(http.StatusNotFound))
tlsAppHandler.Any("/", handlers.IndexHandler(http.StatusOK))
tlsAppHandler.POST(rpcPath+"*", echo.WrapHandler(rpcHandler))
tlsAppHandler.GET("/version", handlers.Version)
tlsAppHandler.GET("/key", handlers.KeyHandler(serverKey))
tlsAppHandler.POST("/ts2021", noiseHandlers.Upgrade)
tlsAppHandler.POST("/machine/:id", registrationHandlers.Register)
tlsAppHandler.POST("/machine/:id/map", pollNetMapHandler.PollNetMap)
tlsAppHandler.POST("/machine/:id/set-dns", dnsHandlers.SetDNS)
tlsAppHandler.GET("/.well-known/jwks", idTokenHandlers.Jwks)
tlsAppHandler.GET("/.well-known/openid-configuration", idTokenHandlers.OpenIDConfig)
webMux.Any("/*", handlers.IndexHandler(http.StatusNotFound))
webMux.Any("/", handlers.IndexHandler(http.StatusOK))
webMux.POST(rpcPath+"*", echo.WrapHandler(rpcHandler))
webMux.GET("/version", handlers.Version)
webMux.GET("/key", handlers.KeyHandler(serverKey))
webMux.POST("/ts2021", noiseHandlers.Upgrade)
webMux.GET("/.well-known/jwks", oidcConfigHandlers.Jwks)
webMux.GET("/.well-known/openid-configuration", oidcConfigHandlers.OpenIDConfig)
auth := tlsAppHandler.Group("/a")
auth.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLookup: "form:_csrf",
}))
auth.GET("/:flow/:key", authenticationHandlers.StartAuth)
auth.POST("/:flow/:key", authenticationHandlers.ProcessAuth)
auth.GET("/callback", authenticationHandlers.Callback)
auth.POST("/callback", authenticationHandlers.EndOAuth)
auth.GET("/success", authenticationHandlers.Success)
auth.GET("/error", authenticationHandlers.Error)
csrf := middleware.CSRFWithConfig(middleware.CSRFConfig{TokenLookup: "form:_csrf"})
webMux.GET("/a/:flow/:key", authenticationHandlers.StartAuth, csrf)
webMux.POST("/a/:flow/:key", authenticationHandlers.ProcessAuth, csrf)
webMux.GET("/a/callback", authenticationHandlers.Callback, csrf)
webMux.POST("/a/callback", authenticationHandlers.EndAuth, csrf)
webMux.GET("/a/success", authenticationHandlers.Success, csrf)
webMux.GET("/a/error", authenticationHandlers.Error, csrf)
tlsL, err := tlsListener(c)
if err != nil {
return err
if !c.DERP.Server.Disabled {
derpHandlers := handlers.NewDERPHandler()
metricsMux.GET("/debug/derp/traffic", derpHandlers.DebugTraffic)
metricsMux.GET("/debug/derp/check", derpHandlers.DebugCheck)
webMux.GET("/derp", derpHandlers.Handler)
webMux.GET("/derp/latency-check", derpHandlers.LatencyCheck)
}
nonTlsL, err := nonTlsListener(c)
webL, err := webListener(c)
if err != nil {
return err
return logError(err)
}
metricsL, err := metricsListener(c)
if err != nil {
return err
return logError(err)
}
httpL := selectListener(tlsL, nonTlsL)
http2Server := &http2.Server{}
g := new(errgroup.Group)
stunL, err := stunListener(c)
if err != nil {
return logError(err)
}
g.Go(func() error { return http.Serve(httpL, h2c.NewHandler(tlsAppHandler, http2Server)) })
g.Go(func() error { return http.Serve(metricsL, metricsHandler) })
errorLog, err := zap.NewStdLogAt(logger, zap.DebugLevel)
if err != nil {
return logError(err)
}
if tlsL != nil {
g.Go(func() error { return http.Serve(nonTlsL, nonTlsAppHandler) })
webServer := &http.Server{ErrorLog: errorLog, Handler: h2c.NewHandler(webMux, &http2.Server{})}
metricsServer := &http.Server{ErrorLog: errorLog, Handler: metricsMux}
stunServer := stunserver.New(stunL)
g, gCtx := errgroup.WithContext(ctx)
go func() {
<-gCtx.Done()
logger.Sugar().Infow("Shutting down ionscale server")
plugin.CleanupClients()
shutdownHttpServer(metricsServer)
shutdownHttpServer(webServer)
_ = stunServer.Shutdown()
}()
g.Go(func() error { return serveHttp(webServer, webL) })
g.Go(func() error { return serveHttp(metricsServer, metricsL) })
g.Go(func() error { return stunServer.Serve() })
fields := []zap.Field{
zap.String("url", c.PublicUrl.String()),
zap.String("addr", c.ListenAddr),
zap.String("metrics_addr", c.MetricsListenAddr),
}
if !c.DERP.Server.Disabled {
fields = append(fields, zap.String("stun_addr", c.StunListenAddr))
} else {
logger.Warn("Embedded DERP is disabled")
}
if c.Tls.AcmeEnabled {
logger.Info("TLS is enabled with ACME", "domain", serverUrl.Host)
logger.Info("Server is running", "http_addr", c.HttpListenAddr, "https_addr", c.HttpsListenAddr, "metrics_addr", c.MetricsListenAddr)
logger.Info("TLS is enabled with ACME", zap.String("domain", c.PublicUrl.Hostname()))
logger.Info("Server is running", fields...)
} else if !c.Tls.Disable {
logger.Info("TLS is enabled", "cert", c.Tls.CertFile)
logger.Info("Server is running", "http_addr", c.HttpListenAddr, "https_addr", c.HttpsListenAddr, "metrics_addr", c.MetricsListenAddr)
logger.Info("TLS is enabled", zap.String("cert", c.Tls.CertFile))
logger.Info("Server is running", fields...)
} else {
logger.Warn("TLS is disabled")
logger.Info("Server is running", "http_addr", c.HttpListenAddr, "metrics_addr", c.MetricsListenAddr)
logger.Info("Server is running", fields...)
}
return g.Wait()
}
func serveHttp(s *http.Server, l net.Listener) error {
if l == nil || s == nil {
return nil
}
if err := s.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
func shutdownHttpServer(s *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.Shutdown(ctx)
}
func setupAuthProvider(config config.Auth) (auth.Provider, *domain.IAMPolicy, error) {
if len(config.Provider.Issuer) == 0 {
return nil, &domain.IAMPolicy{}, nil
@@ -222,20 +291,16 @@ func setupAuthProvider(config config.Auth) (auth.Provider, *domain.IAMPolicy, er
}, nil
}
func metricsListener(config *config.Config) (net.Listener, error) {
return net.Listen("tcp", config.MetricsListenAddr)
}
func tlsListener(config *config.Config) (net.Listener, error) {
func webListener(config *config.Config) (net.Listener, error) {
if config.Tls.Disable {
return nil, nil
return net.Listen("tcp", config.ListenAddr)
}
if config.Tls.AcmeEnabled {
cfg := certmagic.NewDefault()
tlsConfig := cfg.TLSConfig()
tlsConfig.NextProtos = append([]string{"h2", "http/1.1"}, tlsConfig.NextProtos...)
return tls.Listen("tcp", config.HttpsListenAddr, tlsConfig)
return tls.Listen("tcp", config.ListenAddr, tlsConfig)
}
certPEMBlock, err := os.ReadFile(config.Tls.CertFile)
@@ -254,46 +319,72 @@ func tlsListener(config *config.Config) (net.Listener, error) {
tlsConfig := &tls.Config{Certificates: []tls.Certificate{cer}}
return tls.Listen("tcp", config.HttpsListenAddr, tlsConfig)
return tls.Listen("tcp", config.ListenAddr, tlsConfig)
}
func nonTlsListener(config *config.Config) (net.Listener, error) {
return net.Listen("tcp", config.HttpListenAddr)
func metricsListener(config *config.Config) (net.Listener, error) {
return net.Listen("tcp", config.MetricsListenAddr)
}
func selectListener(a net.Listener, b net.Listener) net.Listener {
if a != nil {
return a
func stunListener(config *config.Config) (*net.UDPConn, error) {
if config.DERP.Server.Disabled {
return nil, nil
}
return b
}
func setupLogging(config config.Logging) (hclog.Logger, error) {
file, err := createLogFile(config)
addr, err := net.ResolveUDPAddr("udp", config.StunListenAddr)
if err != nil {
return nil, err
}
appLogger := hclog.New(&hclog.LoggerOptions{
Name: "ionscale",
Level: hclog.LevelFromString(config.Level),
JSONFormat: strings.ToLower(config.Format) == "json",
Output: file,
})
log.SetOutput(appLogger.StandardWriter(&hclog.StandardLoggerOptions{InferLevels: true}))
log.SetPrefix("")
log.SetFlags(0)
return appLogger, nil
return net.ListenUDP("udp", addr)
}
func createLogFile(config config.Logging) (*os.File, error) {
if config.File != "" {
f, err := os.OpenFile(config.File, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
return nil, err
}
return f, nil
func setupLogging(config config.Logging) (*zap.Logger, error) {
level, err := zap.ParseAtomicLevel(config.Level)
if err != nil {
return nil, err
}
return os.Stdout, nil
pc := zap.NewProductionConfig()
pc.Level = level
pc.DisableStacktrace = true
pc.OutputPaths = []string{"stdout"}
pc.Encoding = "console"
pc.EncoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
pc.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
if config.File != "" {
pc.OutputPaths = []string{config.File}
}
if config.Format == "json" {
pc.Encoding = "json"
}
logger, err := pc.Build()
if err != nil {
return nil, err
}
globalLogger := logger.Named("ionscale")
zap.ReplaceGlobals(globalLogger)
return globalLogger, nil
}
func contextWithSigterm(ctx context.Context) context.Context {
ctxWithCancel, cancel := context.WithCancel(ctx)
go func() {
defer cancel()
signalCh := make(chan os.Signal, 1)
signal.Notify(signalCh, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
select {
case <-signalCh:
case <-ctx.Done():
}
}()
return ctxWithCancel
}
+14 -15
View File
@@ -5,8 +5,6 @@ import (
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/errors"
"github.com/jsiebens/ionscale/internal/mapping"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
)
@@ -18,18 +16,13 @@ func (s *Service) GetACLPolicy(ctx context.Context, req *connect.Request[api.Get
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
if tailnet == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet does not exist"))
}
var policy api.ACLPolicy
if err := mapping.CopyViaJson(&tailnet.ACLPolicy, &policy); err != nil {
return nil, errors.Wrap(err, 0)
}
return connect.NewResponse(&api.GetACLPolicyResponse{Policy: &policy}), nil
return connect.NewResponse(&api.GetACLPolicyResponse{Policy: tailnet.ACLPolicy.String()}), nil
}
func (s *Service) SetACLPolicy(ctx context.Context, req *connect.Request[api.SetACLPolicyRequest]) (*connect.Response[api.SetACLPolicyResponse], error) {
@@ -40,20 +33,26 @@ func (s *Service) SetACLPolicy(ctx context.Context, req *connect.Request[api.Set
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
if tailnet == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet does not exist"))
}
var policy domain.ACLPolicy
if err := mapping.CopyViaJson(req.Msg.Policy, &policy); err != nil {
return nil, errors.Wrap(err, 0)
newPolicy, err := domain.ParseHuJson[domain.ACLPolicy](req.Msg.Policy)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid acl policy: %w", err))
}
tailnet.ACLPolicy = policy
oldPolicy := tailnet.ACLPolicy
if oldPolicy.Equal(newPolicy) {
return connect.NewResponse(&api.SetACLPolicyResponse{}), nil
}
tailnet.ACLPolicy = *newPolicy
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
s.sessionManager.NotifyAll(tailnet.ID)
+7 -7
View File
@@ -2,10 +2,10 @@ package service
import (
"context"
"errors"
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/errors"
"github.com/jsiebens/ionscale/internal/util"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"time"
@@ -25,11 +25,11 @@ func (s *Service) Authenticate(ctx context.Context, req *connect.Request[api.Aut
}
if err := s.repository.SaveAuthenticationRequest(ctx, session); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if err := stream.Send(&api.AuthenticateResponse{AuthUrl: authUrl}); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
notify := ctx.Done()
@@ -45,7 +45,7 @@ func (s *Service) Authenticate(ctx context.Context, req *connect.Request[api.Aut
case <-tick.C:
m, err := s.repository.GetAuthenticationRequest(ctx, key)
if err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
if m == nil {
@@ -54,17 +54,17 @@ func (s *Service) Authenticate(ctx context.Context, req *connect.Request[api.Aut
if len(m.Token) != 0 {
if err := stream.Send(&api.AuthenticateResponse{Token: m.Token, TailnetId: m.TailnetID}); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
return nil
}
if len(m.Error) != 0 {
return connect.NewError(connect.CodePermissionDenied, fmt.Errorf(m.Error))
return connect.NewError(connect.CodePermissionDenied, errors.New(m.Error))
}
if err := stream.Send(&api.AuthenticateResponse{AuthUrl: authUrl}); err != nil {
return errors.Wrap(err, 0)
return logError(err)
}
case <-notify:
+10 -11
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/errors"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"google.golang.org/protobuf/types/known/timestamppb"
"time"
@@ -16,7 +15,7 @@ func (s *Service) GetAuthKey(ctx context.Context, req *connect.Request[api.GetAu
key, err := s.repository.GetAuthKey(ctx, req.Msg.AuthKeyId)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
if key == nil {
@@ -80,7 +79,7 @@ func (s *Service) ListAuthKeys(ctx context.Context, req *connect.Request[api.Lis
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
if tailnet == nil {
@@ -92,7 +91,7 @@ func (s *Service) ListAuthKeys(ctx context.Context, req *connect.Request[api.Lis
if principal.IsSystemAdmin() {
authKeys, err := s.repository.ListAuthKeys(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
response.AuthKeys = mapAuthKeysToApi(authKeys)
@@ -102,7 +101,7 @@ func (s *Service) ListAuthKeys(ctx context.Context, req *connect.Request[api.Lis
if principal.User != nil {
authKeys, err := s.repository.ListAuthKeysByTailnetAndUser(ctx, req.Msg.TailnetId, principal.User.ID)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
response.AuthKeys = mapAuthKeysToApi(authKeys)
@@ -128,7 +127,7 @@ func (s *Service) CreateAuthKey(ctx context.Context, req *connect.Request[api.Cr
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
if tailnet == nil {
@@ -136,7 +135,7 @@ func (s *Service) CreateAuthKey(ctx context.Context, req *connect.Request[api.Cr
}
if !principal.IsSystemAdmin() {
if err := tailnet.ACLPolicy.CheckTagOwners(req.Msg.Tags, principal.User); err != nil {
if err := tailnet.ACLPolicy.Get().CheckTagOwners(req.Msg.Tags, principal.User); err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
}
@@ -155,7 +154,7 @@ func (s *Service) CreateAuthKey(ctx context.Context, req *connect.Request[api.Cr
if user == nil {
u, _, err := s.repository.GetOrCreateServiceUser(ctx, tailnet)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
user = u
}
@@ -165,7 +164,7 @@ func (s *Service) CreateAuthKey(ctx context.Context, req *connect.Request[api.Cr
v, authKey := domain.CreateAuthKey(tailnet, user, req.Msg.Ephemeral, req.Msg.PreAuthorized, tags, expiresAt)
if err := s.repository.SaveAuthKey(ctx, authKey); err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
response := api.CreateAuthKeyResponse{
@@ -191,7 +190,7 @@ func (s *Service) DeleteAuthKey(ctx context.Context, req *connect.Request[api.De
key, err := s.repository.GetAuthKey(ctx, req.Msg.AuthKeyId)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
if key == nil {
@@ -203,7 +202,7 @@ func (s *Service) DeleteAuthKey(ctx context.Context, req *connect.Request[api.De
}
if _, err := s.repository.DeleteAuthKey(ctx, req.Msg.AuthKeyId); err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
return connect.NewResponse(&api.DeleteAuthKeyResponse{}), nil
}
+2 -64
View File
@@ -6,10 +6,7 @@ import (
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/errors"
"github.com/jsiebens/ionscale/internal/util"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"tailscale.com/tailcfg"
)
func (s *Service) GetDefaultDERPMap(ctx context.Context, _ *connect.Request[api.GetDefaultDERPMapRequest]) (*connect.Response[api.GetDefaultDERPMapResponse], error) {
@@ -18,71 +15,12 @@ func (s *Service) GetDefaultDERPMap(ctx context.Context, _ *connect.Request[api.
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
dm, err := s.repository.GetDERPMap(ctx)
if err != nil {
return nil, errors.Wrap(err, 0)
}
dm := domain.GetDefaultDERPMap()
raw, err := json.Marshal(dm.DERPMap)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
return connect.NewResponse(&api.GetDefaultDERPMapResponse{Value: raw}), nil
}
func (s *Service) SetDefaultDERPMap(ctx context.Context, req *connect.Request[api.SetDefaultDERPMapRequest]) (*connect.Response[api.SetDefaultDERPMapResponse], error) {
principal := CurrentPrincipal(ctx)
if !principal.IsSystemAdmin() {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
var derpMap tailcfg.DERPMap
if err := json.Unmarshal(req.Msg.Value, &derpMap); err != nil {
return nil, errors.Wrap(err, 0)
}
dp := domain.DERPMap{
Checksum: util.Checksum(&derpMap),
DERPMap: derpMap,
}
if err := s.repository.SetDERPMap(ctx, &dp); err != nil {
return nil, errors.Wrap(err, 0)
}
tailnets, err := s.repository.ListTailnets(ctx)
if err != nil {
return nil, errors.Wrap(err, 0)
}
for _, t := range tailnets {
s.sessionManager.NotifyAll(t.ID)
}
return connect.NewResponse(&api.SetDefaultDERPMapResponse{Value: req.Msg.Value}), nil
}
func (s *Service) ResetDefaultDERPMap(ctx context.Context, req *connect.Request[api.ResetDefaultDERPMapRequest]) (*connect.Response[api.ResetDefaultDERPMapResponse], error) {
principal := CurrentPrincipal(ctx)
if !principal.IsSystemAdmin() {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
dp := domain.DERPMap{}
if err := s.repository.SetDERPMap(ctx, &dp); err != nil {
return nil, errors.Wrap(err, 0)
}
tailnets, err := s.repository.ListTailnets(ctx)
if err != nil {
return nil, errors.Wrap(err, 0)
}
for _, t := range tailnets {
s.sessionManager.NotifyAll(t.ID)
}
return connect.NewResponse(&api.ResetDefaultDERPMapResponse{}), nil
}
+44 -24
View File
@@ -6,7 +6,6 @@ import (
"github.com/bufbuild/connect-go"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/errors"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
)
@@ -18,24 +17,14 @@ func (s *Service) GetDNSConfig(ctx context.Context, req *connect.Request[api.Get
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
if tailnet == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet not found"))
}
dnsConfig := tailnet.DNSConfig
tailnetDomain := domain.SanitizeTailnetName(tailnet.Name)
resp := &api.GetDNSConfigResponse{
Config: &api.DNSConfig{
MagicDns: dnsConfig.MagicDNS,
HttpsCerts: dnsConfig.HttpsCertsEnabled,
MagicDnsSuffix: fmt.Sprintf("%s.%s", tailnetDomain, config.MagicDNSSuffix()),
OverrideLocalDns: dnsConfig.OverrideLocalDNS,
Nameservers: dnsConfig.Nameservers,
Routes: domainRoutesToApiRoutes(dnsConfig.Routes),
},
Config: domainDNSConfigToApiDNSConfig(tailnet),
}
return connect.NewResponse(resp), nil
@@ -53,31 +42,33 @@ func (s *Service) SetDNSConfig(ctx context.Context, req *connect.Request[api.Set
return nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("MagicDNS must be enabled when enabling HTTPS Certs"))
}
if dnsConfig.HttpsCerts && s.dnsProvider == nil {
return nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("A DNS provider must be configured when enabling HTTPS Certs"))
}
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
if tailnet == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet not found"))
}
tailnet.DNSConfig = domain.DNSConfig{
MagicDNS: dnsConfig.MagicDns,
HttpsCertsEnabled: dnsConfig.HttpsCerts,
OverrideLocalDNS: dnsConfig.OverrideLocalDns,
Nameservers: dnsConfig.Nameservers,
Routes: apiRoutesToDomainRoutes(dnsConfig.Routes),
oldConfig := tailnet.DNSConfig
newConfig := apiDNSConfigToDomainDNSConfig(req.Msg.Config)
if oldConfig.Equal(&newConfig) {
return connect.NewResponse(&api.SetDNSConfigResponse{Config: domainDNSConfigToApiDNSConfig(tailnet)}), nil
}
tailnet.DNSConfig = newConfig
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
s.sessionManager.NotifyAll(tailnet.ID)
resp := &api.SetDNSConfigResponse{Config: dnsConfig}
return connect.NewResponse(resp), nil
return connect.NewResponse(&api.SetDNSConfigResponse{Config: domainDNSConfigToApiDNSConfig(tailnet)}), nil
}
func domainRoutesToApiRoutes(routes map[string][]string) map[string]*api.Routes {
@@ -95,3 +86,32 @@ func apiRoutesToDomainRoutes(routes map[string]*api.Routes) map[string][]string
}
return result
}
func apiDNSConfigToDomainDNSConfig(dnsConfig *api.DNSConfig) domain.DNSConfig {
if dnsConfig == nil {
return domain.DNSConfig{}
}
return domain.DNSConfig{
MagicDNS: dnsConfig.MagicDns,
HttpsCertsEnabled: dnsConfig.HttpsCerts,
OverrideLocalDNS: dnsConfig.OverrideLocalDns,
Nameservers: dnsConfig.Nameservers,
Routes: apiRoutesToDomainRoutes(dnsConfig.Routes),
SearchDomains: dnsConfig.SearchDomains,
}
}
func domainDNSConfigToApiDNSConfig(tailnet *domain.Tailnet) *api.DNSConfig {
tailnetDomain := domain.SanitizeTailnetName(tailnet.Name)
dnsConfig := tailnet.DNSConfig
return &api.DNSConfig{
MagicDns: dnsConfig.MagicDNS,
HttpsCerts: dnsConfig.HttpsCertsEnabled,
MagicDnsSuffix: fmt.Sprintf("%s.%s", tailnetDomain, config.MagicDNSSuffix()),
OverrideLocalDns: dnsConfig.OverrideLocalDNS,
Nameservers: dnsConfig.Nameservers,
Routes: domainRoutesToApiRoutes(dnsConfig.Routes),
SearchDomains: dnsConfig.SearchDomains,
}
}
+18 -33
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/errors"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
)
@@ -17,20 +16,13 @@ func (s *Service) GetIAMPolicy(ctx context.Context, req *connect.Request[api.Get
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
if tailnet == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet does not exist"))
}
policy := &api.IAMPolicy{
Subs: tailnet.IAMPolicy.Subs,
Emails: tailnet.IAMPolicy.Emails,
Filters: tailnet.IAMPolicy.Filters,
Roles: domainRolesMapToApiRolesMap(tailnet.IAMPolicy.Roles),
}
return connect.NewResponse(&api.GetIAMPolicyResponse{Policy: policy}), nil
return connect.NewResponse(&api.GetIAMPolicyResponse{Policy: tailnet.IAMPolicy.String()}), nil
}
func (s *Service) SetIAMPolicy(ctx context.Context, req *connect.Request[api.SetIAMPolicyRequest]) (*connect.Response[api.SetIAMPolicyResponse], error) {
@@ -41,38 +33,31 @@ func (s *Service) SetIAMPolicy(ctx context.Context, req *connect.Request[api.Set
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
if tailnet == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet does not exist"))
}
tailnet.IAMPolicy = domain.IAMPolicy{
Subs: req.Msg.Policy.Subs,
Emails: req.Msg.Policy.Emails,
Filters: req.Msg.Policy.Filters,
Roles: apiRolesMapToDomainRolesMap(req.Msg.Policy.Roles),
newPolicy, err := domain.ParseHuJson[domain.IAMPolicy](req.Msg.Policy)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid iam policy: %w", err))
}
if err := validateIamPolicy(newPolicy.Get()); err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid iam policy: %w", err))
}
oldPolicy := tailnet.IAMPolicy
if oldPolicy.Equal(newPolicy) {
return connect.NewResponse(&api.SetIAMPolicyResponse{}), nil
}
tailnet.IAMPolicy = *newPolicy
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
return nil, errors.Wrap(err, 0)
return nil, logError(err)
}
return connect.NewResponse(&api.SetIAMPolicyResponse{}), nil
}
func apiRolesMapToDomainRolesMap(values map[string]string) map[string]domain.UserRole {
var result = map[string]domain.UserRole{}
for k, v := range values {
result[k] = domain.UserRole(v)
}
return result
}
func domainRolesMapToApiRolesMap(values map[string]domain.UserRole) map[string]string {
var result = map[string]string{}
for k, v := range values {
result[k] = string(v)
}
return result
}
+11 -19
View File
@@ -4,11 +4,10 @@ import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
"github.com/hashicorp/go-hclog"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/errors"
"github.com/jsiebens/ionscale/internal/key"
"github.com/jsiebens/ionscale/internal/token"
"go.uber.org/zap"
"strings"
)
@@ -65,7 +64,7 @@ func exchangeToken(ctx context.Context, systemAdminKey *key.ServerPrivate, repos
if err == nil && apiKey != nil {
user := apiKey.User
tailnet := apiKey.Tailnet
role := tailnet.IAMPolicy.GetRole(user)
role := tailnet.IAMPolicy.Get().GetRole(user)
return &domain.Principal{User: &apiKey.User, SystemRole: domain.SystemRoleNone, UserRole: role}
}
@@ -78,14 +77,11 @@ func exchangeToken(ctx context.Context, systemAdminKey *key.ServerPrivate, repos
return nil
}
func NewErrorInterceptor(logger hclog.Logger) *ErrorInterceptor {
return &ErrorInterceptor{
logger: logger,
}
func NewErrorInterceptor() *ErrorInterceptor {
return &ErrorInterceptor{}
}
type ErrorInterceptor struct {
logger hclog.Logger
}
func (e *ErrorInterceptor) handleError(err error) error {
@@ -93,23 +89,14 @@ func (e *ErrorInterceptor) handleError(err error) error {
return err
}
switch t := err.(type) {
switch err.(type) {
case *connect.Error:
return err
case *errors.Error:
e.logger.Error("error processing grpc request",
"err", t.Cause,
"location", t.Location,
)
return connect.NewError(connect.CodeInternal, fmt.Errorf("internal server error"))
default:
e.logger.Error("error processing grpc request",
"err", err,
)
return connect.NewError(connect.CodeInternal, fmt.Errorf("internal server error"))
}
}
func (e *ErrorInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) {
response, err := next(ctx, request)
@@ -129,3 +116,8 @@ func (e *ErrorInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFun
return e.handleError(err)
}
}
func logError(err error) error {
zap.L().WithOptions(zap.AddCallerSkip(1)).Error("error processing request", zap.Error(err))
return err
}

Some files were not shown because too many files have changed in this diff Show More