Compare commits

...

182 Commits

Author SHA1 Message Date
Johan Siebens 9f7263abd5 fix: add machines indeces and use gorm take instead of first 2022-12-27 11:14:53 +01:00
Johan Siebens 660c684a13 chore(deps): upgrade golang.org/x/net 2022-12-25 07:45:28 +01:00
Johan Siebens 790ef5fe1a feat: subnet router failover 2022-12-25 07:40:19 +01:00
Johan Siebens 61d9b40144 chore: remove pubsub and introduce session manager 2022-12-24 12:00:00 +01:00
Johan Siebens a8e8d1aa49 fix: send user and login in registration response 2022-12-21 06:12:41 +01:00
Johan Siebens b2dbe3b9c5 chore: remove unused arg and only set lastseen when offline 2022-11-30 08:31:41 +01:00
Johan Siebens 8c6e9e00b9 chore: ignore eof errors when clients disconnect 2022-11-29 16:51:28 +01:00
Johan Siebens beb856a85d feat: move https certs flag to dns config 2022-11-29 08:28:57 +01:00
Johan Siebens 2345f0b1de feat: improve error handling/logging a little bit 2022-11-23 11:06:26 +01:00
Johan Siebens c8b040fcd6 chore(docs): remove unused attribute 2022-11-16 07:21:00 +01:00
Johan Siebens 5481d3bf4b chore: add check for breaking api 2022-11-16 07:16:11 +01:00
Johan Siebens aac5414a21 chore: update install script 2022-11-05 09:25:26 +01:00
Johan Siebens e74faa2605 feat: machine authorization 2022-11-04 14:13:19 +01:00
Johan Siebens 9baf2ec6d1 chore(deps): bump cosign action 2022-10-31 08:54:49 +01:00
Johan Siebens c73b7e13e0 feat: buf formatting and linting 2022-10-31 07:54:12 +01:00
Johan Siebens e41bac5a41 chore: improve echo handlers 2022-10-29 08:21:14 +02:00
Johan Siebens 03abebb847 chore: one start auth method 2022-10-28 16:02:00 +02:00
Johan Siebens 210cc9c8a2 fix: don't allow tag owners ssh from machine 2022-10-28 14:40:03 +02:00
Johan Siebens 9e38ffc44d fix: check if expiresAt is available 2022-10-25 07:28:56 +02:00
Johan Siebens 06f02c1235 chore(deps): upgrade gorm 2022-10-22 10:36:26 +02:00
Johan Siebens 1de736144a feat: remove the notion of alias 2022-10-22 08:55:32 +02:00
Johan Siebens 2bfe95219d chore(docs): update docs with latest version 2022-10-20 10:49:39 +02:00
Johan Siebens e66fa7eabf chore(deps): upgrade deps 2022-10-20 08:24:31 +02:00
Johan Siebens 4e96f2a5c3 fix: ignore tag src in check actions 2022-10-20 08:11:01 +02:00
Johan Siebens 43167c1fae feat: read config from env 2022-10-19 08:09:34 +02:00
Johan Siebens cf75b9240c chore(docs): some initial docs 2022-10-16 08:15:05 +02:00
Johan Siebens ab9439ecfe chore(docs): update readme 2022-10-15 08:27:43 +02:00
Johan Siebens 429798574d chore: make autoapprovers optional 2022-10-14 12:15:13 +02:00
Johan Siebens aad7a8b6e8 feat: edit iam and acl policies from cli 2022-10-12 08:05:54 +02:00
Johan Siebens a2d97183d2 chore(deps): go mod tidy 2022-10-11 13:15:17 +02:00
Johan Siebens af3a5f3a25 fix: incorrect env variables for auth provider 2022-10-10 12:57:10 +02:00
Johan Siebens fea6a10640 chore: update install script 2022-10-09 18:20:52 +02:00
Johan Siebens 11af121126 feat: remove ephemeral machines on logout 2022-10-09 08:52:58 +02:00
Johan Siebens dfb91d2419 chore: rename provider package to auth package 2022-10-09 08:19:40 +02:00
Johan Siebens daf577a0ee feat: config dns using env variables 2022-10-09 08:13:37 +02:00
Johan Siebens a364188761 chore: check if iam policy is set or not 2022-10-08 12:59:13 +02:00
Johan Siebens ea4fe22e35 chore(deps): upgrade some dependencies 2022-10-08 08:39:26 +02:00
Johan Siebens ddc65d2df9 feat: add support for ssh acl policies 2022-10-08 07:26:30 +02:00
Johan Siebens c70a4cfe6a fix: don't send peer capabilities to nodes 2022-10-07 20:10:30 +02:00
Johan Siebens 5bf919da12 fix: don't send derp map if not changed 2022-10-07 16:43:37 +02:00
Johan Siebens 6d4a7b7014 feat: set default derp map configuration 2022-10-07 16:31:57 +02:00
Johan Siebens bc1f188816 feat: add some command aliases 2022-10-07 15:43:51 +02:00
Johan Siebens 9522e3531e feat: enable/disable taildrop and service collection 2022-10-07 10:12:31 +02:00
Johan Siebens 1e3541e7c8 fix: remove check on nameservers as it is not required anymore for MagicDNS 2022-10-06 21:30:34 +02:00
Johan Siebens c3e1344199 fix: add admin capability flag when needed 2022-10-06 21:19:58 +02:00
Johan Siebens 70b9373df3 feat: set derp map for a tailnet 2022-10-04 16:06:15 +02:00
Johan Siebens 58de86a978 fix: use crypto/rand 2022-10-03 15:52:39 +02:00
Johan Siebens 2e57338b54 feat: add id token handler 2022-09-30 16:13:25 +02:00
Johan Siebens 7cadcc9085 fix: move auth config a level deeper 2022-09-30 15:39:19 +02:00
Johan Siebens 22cfe60c7d feat: add support for https certs 2022-09-30 15:31:57 +02:00
Johan Siebens 45572397ea fix: use correct context 2022-09-28 14:58:04 +02:00
Johan Siebens e5a3d3c589 fix: sanitize tailnet name properly 2022-09-28 11:50:31 +02:00
Johan Siebens 2a5fe7f136 feat: generate control keys by default in db 2022-09-27 16:40:48 +02:00
Johan Siebens 7ee4b27688 feat: add cmd to enable/disable exit nodes and print information properly 2022-09-27 11:14:22 +02:00
Johan Siebens 69f7c22307 feat: add support for autogroup:internet in acls 2022-09-27 09:36:10 +02:00
Johan Siebens 4e5f89ab7e feat: add autoapprovers support in acls 2022-09-27 07:52:37 +02:00
Johan Siebens c1ffe03e81 feat: mark machines as ephemeral when requested by the client 2022-09-25 08:11:10 +02:00
Johan Siebens 7ad91c4c20 feat: add support for autogroup:self and autogroup:members 2022-09-24 15:42:55 +02:00
Johan Siebens fb04248db4 chore(ci): add some security analysis 2022-09-24 09:37:26 +02:00
Johan Siebens d84bad12d0 chore: fixes 2022-09-24 08:16:52 +02:00
Johan Siebens cadf938e2a chore(ci): update build targets 2022-09-24 08:09:26 +02:00
Johan Siebens 980ae6dd85 feat: add flags to create tailnet with some proper default IAM policies 2022-09-23 14:04:23 +02:00
Johan Siebens 6e3e22bc72 chore: remove config flags for now 2022-09-23 10:36:55 +02:00
Johan Siebens 0051eec355 feat: configure magic dns suffix 2022-09-22 18:23:42 +02:00
Johan Siebens 617575803c chore: remove auth provider config from flags and env variables 2022-09-22 18:05:03 +02:00
Johan Siebens 8c6ea9041b fix: system admin can always use tags 2022-09-22 16:49:38 +02:00
Johan Siebens c6ebeb36bc fix: improve some default values 2022-09-22 15:47:35 +02:00
Johan Siebens d87c7252c2 chore: update script 2022-09-22 14:31:23 +02:00
Johan Siebens bfcf0c7925 feat: configure server using flags 2022-09-21 08:15:39 +02:00
Johan Siebens aea3d2d6a9 chore: some better error descriptions 2022-09-20 12:54:29 +02:00
Johan Siebens 9781e75833 chore: add data dir 2022-09-20 12:41:46 +02:00
Johan Siebens 47b15d31f0 fix: make config file optional 2022-09-20 12:41:22 +02:00
Johan Siebens ec353f7add feat: flag to disable newline in genkey output 2022-09-20 09:06:01 +02:00
Johan Siebens 92ca75b7f4 fix: remove _ from tag_owners, make it more compliant 2022-09-19 12:01:55 +02:00
Johan Siebens 1702cf135e fix: change precedence order, env variables overrule file config 2022-09-17 08:34:37 +02:00
Johan Siebens b65119bbba fix: system admin is always a tag owner 2022-09-17 07:13:06 +02:00
Johan Siebens b265fc42c7 feat: implement tag owners 2022-09-16 16:30:51 +02:00
Johan Siebens 69dd1f6b95 fix: don't strip tag: prefix from tag values 2022-09-16 14:49:06 +02:00
Johan Siebens ebf0016096 fix: always show machines of the same user 2022-09-16 14:40:23 +02:00
Johan Siebens 3aa2d68ce2 fix: validate tags when creating auth keys 2022-09-16 14:20:50 +02:00
Johan Siebens 3d03f49138 feat: update on how to show dns config 2022-09-16 14:04:48 +02:00
Johan Siebens c4783f8165 chore: cleanup event listener tool 2022-09-16 13:15:31 +02:00
Johan Siebens 3b9ce04ec8 feat: add methods to enable and disable single routes 2022-09-16 11:33:14 +02:00
Johan Siebens f71ca49693 fix: send correct nameservers when using split dns 2022-09-16 10:20:54 +02:00
Johan Siebens 61d78fe121 chore: don't save default derp in db 2022-09-10 15:58:02 +02:00
Johan Siebens 5b51e29140 fix: disable logtail for now 2022-09-10 12:29:40 +02:00
Johan Siebens e5ed4713d8 feat: make keep alive interval configurable 2022-09-10 12:25:30 +02:00
Johan Siebens 9281deb549 feat: force http to https redirect even when tls is disabled 2022-09-10 09:18:32 +02:00
Johan Siebens 88509c826d feature: force https 2022-09-10 08:34:40 +02:00
Johan Siebens 405110867a fix: change port to match default server config 2022-09-10 08:12:44 +02:00
Johan Siebens 82c814aa2a fix: change metrics port to 9091, a more default port for prometheus clients 2022-09-10 08:12:14 +02:00
Johan Siebens 5a524d7357 chore: go mod tidy 2022-09-10 08:11:28 +02:00
Johan Siebens 0f0829ccba fix: use same name for admin key env variable in client and server 2022-09-10 07:28:04 +02:00
Johan Siebens 4c9ea463db feat: add number of connected machines metric 2022-09-09 22:17:06 +02:00
Johan Siebens 284ec18339 feat: add command to generate a simple config 2022-09-09 21:21:57 +02:00
Johan Siebens 5a77d2b35b feat: decouple db migrations from domain model 2022-09-07 10:36:36 +02:00
Johan Siebens c193a4bf71 fix: correct registration and cli authentication flows 2022-09-07 10:25:40 +02:00
Johan Siebens 550febc5ba fix: use abs path, giving a proper error when file does not exists 2022-09-06 16:25:21 +02:00
Johan Siebens f0d71c8a66 chore: bump alpine version 2022-09-06 16:25:21 +02:00
Johan Siebens 3c50d4869d Create CONTRIBUTING.md 2022-09-06 15:08:10 +02:00
Johan Siebens e8fe0e2467 chore(deps): upgrade dependencies 2022-09-06 14:55:22 +02:00
Johan Siebens 633f29003c chore(deps): upgrade tailscale dependency 2022-09-06 14:49:31 +02:00
Johan Siebens 145ae6ab1d chore: bump to go 1.19 2022-09-06 11:36:31 +02:00
Johan Siebens b60e332cbd docs: update readme 2022-09-06 09:48:59 +02:00
Johan Siebens f38939415d feat: lock database when migrating 2022-09-06 08:19:45 +02:00
Johan Siebens 49e5c7999f feat: make system admin key optional 2022-09-05 17:07:33 +02:00
Johan Siebens 82a28e32c0 feat: read control and legacy control key from environment 2022-09-05 16:58:53 +02:00
Johan Siebens 7976e7aa83 fix: add env variable for enabling acme 2022-09-05 16:58:17 +02:00
Johan Siebens 404b667aaf chore: rename cert magic config to acme 2022-09-05 16:16:09 +02:00
Johan Siebens 6700d0db01 feat: add support for postgres 2022-09-05 16:01:22 +02:00
Johan Siebens 25ee5a21a6 feat: save current tailnet for subsequent requests 2022-09-05 11:38:25 +02:00
Johan Siebens d735974406 fix: add csrf and remove need of a cache 2022-09-03 17:33:22 +02:00
Johan Siebens 41827dcdcd fix: increase poll frequency when waiting for authentication 2022-08-31 16:35:55 +02:00
Johan Siebens cd1854f510 chore(ci): bump checkout version 2022-08-31 14:23:40 +02:00
Johan Siebens 6a6049b76b chore(ci): pin go version 2022-08-31 13:59:39 +02:00
Johan Siebens 50d52ae481 chore(ci): add nightly job 2022-08-31 13:08:49 +02:00
Johan Siebens 402f98b688 chore(deps): upgrade cosign action 2022-08-31 13:02:25 +02:00
Johan Siebens 4234c5eed9 feat: login as system admin using oidc 2022-08-31 11:21:31 +02:00
Johan Siebens 3568764ec1 feat: get machine details 2022-08-26 09:34:15 +02:00
Johan Siebens df02644437 fix: expiration timestamp and disable flag 2022-08-25 15:59:52 +02:00
Johan Siebens 7db10b563d fix: update expiration date when authentication is succesful 2022-08-25 09:04:23 +02:00
Johan Siebens 496fd5f47c chore: configure auth provider using env variables 2022-08-22 13:22:17 +02:00
Johan Siebens 200b523ae0 chore: rename to AuthProvider 2022-07-15 07:57:56 +02:00
Johan Siebens f225f427ac fix: foreign key violation when deleting tailnet 2022-07-15 07:43:31 +02:00
Johan Siebens 70e84be8f4 feat: delete users 2022-07-15 07:39:19 +02:00
Johan Siebens 409dd3aa5f fix: exclude 'service' users in list 2022-07-14 07:54:07 +02:00
Johan Siebens 0d5ffa9c8b feat: read keys from config file 2022-07-06 07:55:02 +02:00
Johan Siebens 0756de5bfb fix: send dns update correctly 2022-07-02 08:42:13 +02:00
Johan Siebens 32cb12e286 chore: remove auth method and configure oidc via config file 2022-07-02 08:31:59 +02:00
Johan Siebens f6961cf2f7 feat: delete auth method 2022-06-28 09:38:31 +02:00
Johan Siebens ba379e1b65 feat: list users 2022-06-22 08:26:59 +02:00
Johan Siebens 12eb258e1e feat: user roles 2022-06-22 07:47:09 +02:00
Johan Siebens 32c396a972 fix: incorrect json tag 2022-06-16 11:16:58 +02:00
Johan Siebens d0e69cc2bf feat: add method to get auth method 2022-06-16 09:12:54 +02:00
Johan Siebens 5e132392b3 chore: remove println 2022-06-14 14:41:23 +02:00
Johan Siebens 58e1f38231 fix: type safe acl policy in api 2022-06-14 14:41:09 +02:00
Johan Siebens d5f71224f6 feat: disable and enable key expiry 2022-06-12 08:31:04 +02:00
Johan Siebens 090e5c3c88 chore: add dns config as field of tailnet 2022-06-10 15:55:52 +02:00
Johan Siebens 8e8646b757 chore: add acl policy as field of tailnet 2022-06-10 15:49:07 +02:00
Johan Siebens a94e0ce9b8 feat: remove auth-filter in favor of a new IAM Policy setup 2022-06-10 15:36:21 +02:00
Johan Siebens eefa150738 fix: check if an auth method is available when authenticating with the cli 2022-06-06 15:30:35 +02:00
Johan Siebens bbe9d16294 feat: user auth 2022-06-06 15:21:13 +02:00
Johan Siebens 5fdde45fdd fix: add some more information and checks on cli flags 2022-06-06 15:19:44 +02:00
Johan Siebens 1715eb681d fix: remove whitespace when printing new key 2022-06-06 12:34:41 +02:00
Johan Siebens 9d29644941 chore: remove unused code 2022-06-05 14:25:58 +02:00
Johan Siebens da71a43990 feat: replace grpc with buf connect 2022-06-03 14:25:31 +02:00
Johan Siebens 687fcd16d1 fix: only expire machines from tailnet of the auth filter 2022-06-01 16:16:09 +02:00
Johan Siebens b9b42d8342 chore: remove unused code 2022-06-01 10:13:44 +02:00
Johan Siebens 1654680cab feat: set default key expiry 2022-06-01 10:13:44 +02:00
Johan Siebens 9df514036e chore(deps): updates 2022-05-30 10:49:46 +00:00
Johan Siebens 85656c19a7 chore: store pending registration requests in db 2022-05-28 08:43:48 +02:00
Johan Siebens 2b5439bd60 feat: delete auth filters 2022-05-28 07:25:48 +02:00
Johan Siebens 198b6795b1 feat: add auth filters 2022-05-28 07:25:48 +02:00
Johan Siebens 84a57ea409 feat: add support for oidc providers and users 2022-05-28 07:25:48 +02:00
Johan Siebens 37e94ac915 fix: return error when an invalid auth key is used 2022-05-28 07:25:02 +02:00
Johan Siebens 00554118f6 fix: wait until authurl/followup url is visited 2022-05-28 07:17:26 +02:00
Johan Siebens 0e64765b13 chore(deps): upgrade yaml v3 2022-05-26 08:10:05 +02:00
Johan Siebens 03fd19958a fix: display tagged devices in user profile 2022-05-24 20:35:05 +02:00
Johan Siebens e8dc2ee34f feat: add command to expire a machine 2022-05-23 20:26:58 +02:00
Johan Siebens 0e3ca9f419 feat: add api methods to get tailnet and authkey by id 2022-05-21 08:27:31 +02:00
Johan Siebens 482194a506 fix: add tags in response when creating an auth key 2022-05-20 17:45:31 +02:00
Johan Siebens 9a5be02dbb fix: remove duplicate entries in filter rules 2022-05-20 14:42:07 +02:00
Johan Siebens c04a5e26d1 feat: set and get derp map 2022-05-20 14:33:16 +02:00
Johan Siebens 557c43192a chore: initial install script 2022-05-20 11:50:54 +02:00
Johan Siebens 68223f9c8d change name template for checksums 2022-05-20 10:56:36 +02:00
Johan Siebens fadaca6ec7 enable prerelease and pusblish binaries 2022-05-20 10:16:01 +02:00
Johan Siebens 6ae82edf70 chore: load certmagic async 2022-05-17 22:51:25 +02:00
Johan Siebens 0a9aab79e0 feat: add command to generate a server key 2022-05-17 22:46:35 +02:00
Johan Siebens a804aea79b chore: introduce server key 2022-05-18 11:12:39 +02:00
Johan Siebens b1974d7f83 feat: generate and store control keys 2022-05-17 21:11:50 +02:00
Johan Siebens 6365869da2 github actions 2022-05-17 12:50:28 +02:00
Johan Siebens f5a2719313 feat: add support for certmagic 2022-05-17 12:02:51 +02:00
Johan Siebens 3d629a0f93 feat: allow ts2021 protocol using plain http 2022-05-17 12:02:51 +02:00
Johan Siebens 9769f40db5 feat: delete tailnet 2022-05-17 01:01:09 +02:00
Johan Siebens c74a082660 feat: add support for grpc web 2022-05-15 08:24:45 +02:00
Johan Siebens 2d4f614592 feat: acl rules based on cidr ranges 2022-05-15 08:11:45 +02:00
Johan Siebens 3aceacbc8d feat: view and enable routes on machines 2022-05-15 07:10:05 +02:00
Johan Siebens 52aa221cd0 feat: configure dns preferecens for tailnets 2022-05-12 22:32:59 +02:00
Johan Siebens e5c7a118a8 feat: configure ACL policies based on tags and hosts 2022-05-12 20:32:01 +02:00
Johan Siebens 22cccceca9 display tags of auth keys
Signed-off-by: Johan Siebens <johan.siebens@gmail.com>
2022-05-10 16:25:33 +02:00
Johan Siebens ee262b1a35 display tags instead of user 2022-05-10 14:00:25 +02:00
Johan Siebens c55a956507 build and release with goreleaser
Signed-off-by: Johan Siebens <johan.siebens@gmail.com>
2022-05-10 13:50:07 +02:00
161 changed files with 21989 additions and 3651 deletions
+9
View File
@@ -0,0 +1,9 @@
## Contribution Policy
ionscale is open to code contributions for bug fixes only.
Features carry a long-term maintenance burden so they will not be accepted at this time.
Please [submit an issue][new-issue] if you have a feature you'd like to
request.
[new-issue]: https://github.com/jsiebens/ionscale/issues/new
+36
View File
@@ -0,0 +1,36 @@
name: build
on:
push:
branches:
- '*'
pull_request:
branches:
- main
jobs:
buf-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Buf
uses: bufbuild/buf-setup-action@v1
- name: Buf Lint
uses: bufbuild/buf-lint-action@v1
with:
input: proto
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.19
- name: Build
run: |
go test ./...
go build cmd/ionscale/main.go
+23
View File
@@ -0,0 +1,23 @@
name: docs
on:
push:
branches: ['main']
paths: ['mkdocs/**']
permissions:
pages: write
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: 3.x
- run: pip install mkdocs-material
- run: cd mkdocs && mkdocs gh-deploy --force
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+42
View File
@@ -0,0 +1,42 @@
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 }}
+45
View File
@@ -0,0 +1,45 @@
name: release
on:
push:
tags:
- '*'
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 --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
+48
View File
@@ -0,0 +1,48 @@
name: "Security Analysis"
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 0'
jobs:
codeql:
name: CodeQL
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: go
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
trivy:
name: Trivy
runs-on: ubuntu-latest
permissions:
actions: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
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
with:
sarif_file: 'trivy-results.sarif'
+100
View File
@@ -0,0 +1,100 @@
project_name: ionscale
nightly:
name_template: '{{ incminor .Version }}-dev'
before:
hooks:
- go mod tidy
builds:
- main: ./cmd/ionscale
env: [ CGO_ENABLED=0 ]
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
ldflags:
- -s -w -X github.com/jsiebens/ionscale/internal/version.Version={{.Version}} -X github.com/jsiebens/ionscale/internal/version.Revision={{.ShortCommit}}
checksum:
name_template: "checksums.txt"
dockers:
- image_templates: [ "ghcr.io/jsiebens/{{ .ProjectName }}:{{ .Version }}-amd64" ]
dockerfile: Dockerfile
use: buildx
build_flag_templates:
- --platform=linux/amd64
- image_templates: [ "ghcr.io/jsiebens/{{ .ProjectName }}:{{ .Version }}-arm64" ]
goarch: arm64
dockerfile: Dockerfile
use: buildx
build_flag_templates:
- --platform=linux/arm64
docker_manifests:
- name_template: ghcr.io/jsiebens/{{ .ProjectName }}:{{ .Version }}
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 }}
image_templates:
- ghcr.io/jsiebens/{{ .ProjectName }}:{{ .Version }}-amd64
- ghcr.io/jsiebens/{{ .ProjectName }}:{{ .Version }}-arm64
signs:
- cmd: cosign
env:
- COSIGN_EXPERIMENTAL=1
certificate: '${artifact}.pem'
args:
- sign-blob
- '--output-certificate=${certificate}'
- '--output-signature=${signature}'
- '${artifact}'
artifacts: checksum
docker_signs:
- cmd: cosign
env:
- COSIGN_EXPERIMENTAL=1
artifacts: all
output: true
args:
- sign
- '${artifact}'
archives:
- format: binary
name_template: '{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}'
release:
prerelease: auto
changelog:
sort: asc
filters:
exclude:
- '^test:'
- '^chore'
- '^docs'
- Merge pull request
- Merge remote-tracking branch
- Merge branch
- go mod tidy
groups:
- title: 'New Features'
regexp: "^.*feat[(\\w)]*:+.*$"
order: 0
- title: 'Bug fixes'
regexp: "^.*fix[(\\w)]*:+.*$"
order: 10
- title: Other work
order: 999
+8
View File
@@ -0,0 +1,8 @@
FROM --platform=${BUILDPLATFORM:-linux/amd64} alpine:3.16.2
COPY ionscale /usr/local/bin/ionscale
RUN mkdir -p /data/ionscale
WORKDIR /data/ionscale
ENTRYPOINT ["/usr/local/bin/ionscale"]
+16 -1
View File
@@ -1,2 +1,17 @@
init:
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:
buf generate proto
buf generate proto
format:
buf format -w proto
lint:
buf lint proto
breaking:
buf breaking proto --against https://github.com/jsiebens/ionscale.git#subdir=proto
+38
View File
@@ -1 +1,39 @@
# ionscale
> **Note**:
> ionscale is currently beta quality, actively being developed and so subject to changes
**What is Tailscale?**
[Tailscale](https://tailscale.com) is a VPN service that makes the devices and applications you own accessible anywhere in the world, securely and effortlessly.
It enables encrypted point-to-point connections using the open source [WireGuard](https://www.wireguard.com/) protocol, which means only devices on your private network can communicate with each other.
**What is ionscale?**
While the Tailscale software running on each node is open source, their centralized "coordination server" which act as a shared drop box for public keys is not.
_ionscale_ aims to implement such lightweight, open source alternative Tailscale control server.
## 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/)
- [DNS](https://tailscale.com/kb/1054/dns/)
- nameservers
- Split DNS
- MagicDNS
- [HTTPS Certs](https://tailscale.com/kb/1153/enabling-https/)
- [Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh/)
- [Service collection](https://tailscale.com/kb/1100/services/)
- [Taildrop](https://tailscale.com/kb/1106/taildrop/)
## Documentation
Some documentation can be found [here](https://jsiebens.github.io/ionscale)
## Disclaimer
This is not an official Tailscale or Tailscale Inc. project.
+3 -6
View File
@@ -1,11 +1,8 @@
version: v1beta1
version: v1
plugins:
- name: go
out: pkg/gen
opt: paths=source_relative
- name: go-grpc
- name: connect-go
out: pkg/gen
opt: paths=source_relative,require_unimplemented_servers=false
- name: grpc-gateway
out: pkg/gen
opt: paths=source_relative
opt: paths=source_relative
+119 -44
View File
@@ -1,78 +1,153 @@
module github.com/jsiebens/ionscale
go 1.18
go 1.19
require (
github.com/apparentlymart/go-cidr v1.1.0
github.com/glebarez/sqlite v1.4.3
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0-rc.2
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0
github.com/hashicorp/go-hclog v1.1.0
github.com/klauspost/compress v1.15.3
github.com/labstack/echo-contrib v0.12.0
github.com/labstack/echo/v4 v4.6.3
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/hashicorp/go-multierror v1.1.1
github.com/imdario/mergo v0.3.12
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/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/patrickmn/go-cache v2.1.0+incompatible
github.com/prometheus/client_golang v1.13.0
github.com/rodaine/table v1.0.1
github.com/soheilhy/cmux v0.1.5
github.com/sony/sonyflake v1.0.0
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.0.0-20220427172511-eb4f295cb31f
golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
google.golang.org/grpc v1.44.0
google.golang.org/protobuf v1.27.1
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
gorm.io/gorm v1.23.5
inet.af/netaddr v0.0.0-20211027220019-c74959edd3b6
tailscale.com v1.24.2
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
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
)
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
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/glebarez/go-sqlite v1.16.0 // indirect
github.com/form3tech-oss/jwt-go v3.2.2+incompatible // indirect
github.com/glebarez/go-sqlite v1.19.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.7 // 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/inconshreveable/mousetrap v1.0.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/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/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/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.1.2-0.20220408201609-d380b505068b // indirect
github.com/labstack/gommon v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // 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/prometheus/client_golang v1.11.0 // indirect
github.com/mholt/acmez v1.0.4 // indirect
github.com/miekg/dns v1.1.50 // 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.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // 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.0 // indirect
github.com/tkuchiki/go-timezone v0.2.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect
go4.org/mem v0.0.0-20210711025021-927187094b94 // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20211027215541-db492cf91b37 // indirect
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect
golang.zx2c4.com/wireguard/windows v0.4.10 // indirect
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // 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
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.14.12 // indirect
modernc.org/mathutil v1.4.1 // indirect
modernc.org/memory v1.0.7 // indirect
modernc.org/sqlite v1.16.0 // 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
)
+671 -220
View File
File diff suppressed because it is too large Load Diff
+28 -12
View File
@@ -3,18 +3,34 @@ package addr
import (
"github.com/apparentlymart/go-cidr/cidr"
"github.com/jsiebens/ionscale/internal/util"
"inet.af/netaddr"
"math/big"
"net"
"net/netip"
"tailscale.com/net/tsaddr"
)
var ipv4Range = tsaddr.CGNATRange().IPNet()
var (
ipv4Range *net.IPNet
ipv4Count uint64
)
type Predicate func(netaddr.IP) (bool, error)
func init() {
ipv4Range, ipv4Count = prepareIP4Range()
}
func SelectIP(predicate Predicate) (*netaddr.IP, *netaddr.IP, error) {
ip4, err := selectIP(ipv4Range, predicate)
func prepareIP4Range() (*net.IPNet, uint64) {
cgnatRange := tsaddr.CGNATRange()
_, ipNet, err := net.ParseCIDR(cgnatRange.String())
if err != nil {
panic(err)
}
return ipNet, cidr.AddressCount(ipNet)
}
type Predicate func(netip.Addr) (bool, error)
func SelectIP(predicate Predicate) (*netip.Addr, *netip.Addr, error) {
ip4, err := selectIP(predicate)
if err != nil {
return nil, nil, err
}
@@ -22,16 +38,16 @@ func SelectIP(predicate Predicate) (*netaddr.IP, *netaddr.IP, error) {
return ip4, &ip6, err
}
func selectIP(c *net.IPNet, predicate Predicate) (*netaddr.IP, error) {
count := cidr.AddressCount(c)
var n = util.RandUint64(count)
func selectIP(predicate Predicate) (*netip.Addr, error) {
var n = util.RandUint64(ipv4Count)
for {
stdIP, err := cidr.HostBig(c, big.NewInt(int64(n)))
stdIP, err := cidr.HostBig(ipv4Range, big.NewInt(int64(n)))
if err != nil {
return nil, err
}
ip, _ := netaddr.FromStdIP(stdIP)
ip, _ := netip.AddrFromSlice(stdIP)
ok, err := validateIP(ip, predicate)
if err != nil {
return nil, err
@@ -39,11 +55,11 @@ func selectIP(c *net.IPNet, predicate Predicate) (*netaddr.IP, error) {
if ok {
return &ip, nil
}
n = (n + 1) % count
n = (n + 1) % ipv4Count
}
}
func validateIP(ip netaddr.IP, p Predicate) (bool, error) {
func validateIP(ip netip.Addr, p Predicate) (bool, error) {
if tsaddr.IsTailscaleIP(ip) {
if p != nil {
return p(ip)
+137
View File
@@ -0,0 +1,137 @@
package auth
import (
"context"
"fmt"
"github.com/jsiebens/ionscale/internal/config"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type OIDCProvider struct {
clientID string
clientSecret string
scopes []string
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
}
func NewOIDCProvider(c *config.AuthProvider) (*OIDCProvider, error) {
defaultScopes := []string{oidc.ScopeOpenID, "email", "profile"}
provider, err := oidc.NewProvider(context.Background(), c.Issuer)
if err != nil {
return nil, err
}
verifier := provider.Verifier(&oidc.Config{ClientID: c.ClientID, SkipClientIDCheck: c.ClientID == ""})
return &OIDCProvider{
clientID: c.ClientID,
clientSecret: c.ClientSecret,
scopes: append(defaultScopes, c.Scopes...),
provider: provider,
verifier: verifier,
}, nil
}
func (p *OIDCProvider) GetLoginURL(redirectURI, state string) string {
oauth2Config := oauth2.Config{
ClientID: p.clientID,
ClientSecret: p.clientSecret,
RedirectURL: redirectURI,
Endpoint: p.provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
return oauth2Config.AuthCodeURL(state, oauth2.ApprovalForce)
}
func (p *OIDCProvider) Exchange(redirectURI, code string) (*User, error) {
oauth2Config := oauth2.Config{
ClientID: p.clientID,
ClientSecret: p.clientSecret,
RedirectURL: redirectURI,
Endpoint: p.provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
oauth2Token, err := oauth2Config.Exchange(context.Background(), code)
if err != nil {
return nil, err
}
// Extract the ID Token from OAuth2 token.
rawIdToken, ok := oauth2Token.Extra("id_token").(string)
if !ok || strings.TrimSpace(rawIdToken) == "" {
return nil, fmt.Errorf("id_token missing")
}
// Parse and verify ID Token payload.
idToken, err := p.verifier.Verify(context.Background(), rawIdToken)
if err != nil {
return nil, err
}
sub, email, tokenClaims, err := p.getTokenClaims(idToken)
if err != nil {
return nil, err
}
userInfoClaims, err := p.getUserInfoClaims(oauth2Config, oauth2Token)
if err != nil {
return nil, err
}
domain := strings.Split(email, "@")[1]
return &User{
ID: sub,
Name: email,
Attr: map[string]interface{}{
"email": email,
"domain": domain,
"token": tokenClaims,
"userinfo": userInfoClaims,
},
}, nil
}
func (p *OIDCProvider) getTokenClaims(idToken *oidc.IDToken) (string, string, map[string]interface{}, error) {
var raw = make(map[string]interface{})
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
}
// Extract default claims.
if err := idToken.Claims(&claims); err != nil {
return "", "", nil, fmt.Errorf("failed to parse id_token claims: %v", err)
}
// Extract raw claims.
if err := idToken.Claims(&raw); err != nil {
return "", "", nil, fmt.Errorf("failed to parse id_token claims: %v", err)
}
return claims.Sub, claims.Email, raw, nil
}
func (p *OIDCProvider) getUserInfoClaims(config oauth2.Config, token *oauth2.Token) (map[string]interface{}, error) {
var raw = make(map[string]interface{})
source := config.TokenSource(context.Background(), token)
info, err := p.provider.UserInfo(context.Background(), source)
if err != nil {
return nil, err
}
if err := info.Claims(&raw); err != nil {
return nil, fmt.Errorf("failed to parse user info claims: %v", err)
}
return raw, nil
}
+12
View File
@@ -0,0 +1,12 @@
package auth
type Provider interface {
GetLoginURL(redirectURI, state string) string
Exchange(redirectURI, code string) (*User, error)
}
type User struct {
ID string
Name string
Attr map[string]interface{}
}
-102
View File
@@ -1,102 +0,0 @@
package broker
import (
"sync"
"tailscale.com/types/key"
)
type BrokerPool struct {
lock sync.Mutex
store map[uint64]Broker
}
type Signal struct {
PeerUpdated *uint64
PeersRemoved []uint64
}
type Broker interface {
AddClient(*Client)
RemoveClient(uint64)
SignalPeerUpdated(id uint64)
SignalPeersRemoved([]uint64)
IsConnected(uint64) bool
}
func NewBrokerPool() *BrokerPool {
return &BrokerPool{
store: make(map[uint64]Broker),
}
}
func (m *BrokerPool) Get(tailnetID uint64) Broker {
m.lock.Lock()
defer m.lock.Unlock()
b, ok := m.store[tailnetID]
if !ok {
b = newBroker(tailnetID)
m.store[tailnetID] = b
}
return b
}
func newBroker(tailnetID uint64) Broker {
b := &broker{
tailnetID: tailnetID,
newClients: make(chan *Client),
closingClients: make(chan uint64),
clients: make(map[uint64]*Client),
signalChannel: make(chan *Signal),
}
go b.listen()
return b
}
type broker struct {
tailnetID uint64
privateKey *key.MachinePrivate
newClients chan *Client
closingClients chan uint64
signalChannel chan *Signal
clients map[uint64]*Client
}
func (h *broker) IsConnected(id uint64) (ok bool) {
_, ok = h.clients[id]
return
}
func (h *broker) AddClient(client *Client) {
h.newClients <- client
}
func (h *broker) RemoveClient(id uint64) {
h.closingClients <- id
}
func (h *broker) SignalPeerUpdated(id uint64) {
h.signalChannel <- &Signal{PeerUpdated: &id}
}
func (h *broker) SignalPeersRemoved(ids []uint64) {
h.signalChannel <- &Signal{PeersRemoved: ids}
}
func (h *broker) listen() {
for {
select {
case s := <-h.newClients:
h.clients[s.id] = s
case s := <-h.closingClients:
delete(h.clients, s)
case s := <-h.signalChannel:
for _, c := range h.clients {
c.SignalUpdate(s)
}
}
}
}
-26
View File
@@ -1,26 +0,0 @@
package broker
import (
"github.com/jsiebens/ionscale/internal/bind"
"tailscale.com/tailcfg"
)
func NewClient(id uint64, channel chan *Signal) Client {
return Client{
id: id,
channel: channel,
}
}
type Client struct {
id uint64
binder bind.Binder
node *tailcfg.Node
compress string
channel chan *Signal
}
func (c *Client) SignalUpdate(s *Signal) {
c.channel <- s
}
+175
View File
@@ -0,0 +1,175 @@
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"
"os"
)
func getACLConfigCommand() *coral.Command {
command := &coral.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()
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))
return nil
}
return command
}
func editACLConfigCommand() *coral.Command {
command := &coral.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 {
edit := editor.NewDefaultEditor([]string{"IONSCALE_EDITOR", "EDITOR"})
client, err := target.createGRPCClient()
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))
if err != nil {
return err
}
defer os.Remove(s)
var policy = &api.ACLPolicy{}
if err := json.Unmarshal(next, policy); err != nil {
return err
}
_, err = client.SetACLPolicy(context.Background(), connect.NewRequest(&api.SetACLPolicyRequest{TailnetId: tailnet.Id, Policy: policy}))
if err != nil {
return err
}
fmt.Println("ACL policy updated successfully")
return nil
}
return command
}
func setACLConfigCommand() *coral.Command {
command := &coral.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)
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}))
if err != nil {
return err
}
fmt.Println("ACL policy updated successfully")
return nil
}
return command
}
+80
View File
@@ -0,0 +1,80 @@
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"
)
func authCommand() *coral.Command {
command := &coral.Command{
Use: "auth",
}
command.AddCommand(authLoginCommand())
return command
}
func authLoginCommand() *coral.Command {
command := &coral.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
}
req := &api.AuthenticateRequest{}
stream, err := client.Authenticate(context.Background(), connect.NewRequest(req))
if err != nil {
return err
}
var started = false
for stream.Receive() {
resp := stream.Msg()
if len(resp.Token) != 0 {
fmt.Println()
fmt.Println("Success.")
if err := ionscale.SessionToFile(resp.Token, resp.TailnetId); err != nil {
fmt.Println()
fmt.Println("Your api token:")
fmt.Println()
fmt.Printf(" %s\n", resp.Token)
fmt.Println()
}
return nil
}
if len(resp.AuthUrl) != 0 && !started {
started = true
fmt.Println()
fmt.Println("To authenticate, visit:")
fmt.Println()
fmt.Printf(" %s\n", resp.AuthUrl)
fmt.Println()
}
}
if err := stream.Err(); err != nil {
return err
}
return nil
}
return command
}
+36 -27
View File
@@ -3,17 +3,21 @@ package cmd
import (
"context"
"fmt"
"github.com/jsiebens/ionscale/pkg/gen/api"
"github.com/bufbuild/connect-go"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"github.com/rodaine/table"
str2dur "github.com/xhit/go-str2duration/v2"
"google.golang.org/protobuf/types/known/durationpb"
"strings"
"time"
)
func authkeysCommand() *coral.Command {
command := &coral.Command{
Use: "auth-keys",
Use: "auth-keys",
Aliases: []string{"auth-key"},
Short: "Manage ionscale auth keys",
}
command.AddCommand(createAuthkeysCommand())
@@ -26,29 +30,32 @@ func authkeysCommand() *coral.Command {
func createAuthkeysCommand() *coral.Command {
command := &coral.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", "", "")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "")
command.Flags().BoolVar(&ephemeral, "ephemeral", false, "")
command.Flags().StringSliceVar(&tags, "tag", []string{}, "")
command.Flags().StringVar(&expiry, "expiry", "180d", "")
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, c, err := target.createGRPCClient()
client, err := target.createGRPCClient()
if err != nil {
return err
}
defer safeClose(c)
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
@@ -66,12 +73,13 @@ func createAuthkeysCommand() *coral.Command {
}
req := &api.CreateAuthKeyRequest{
TailnetId: tailnet.Id,
Ephemeral: ephemeral,
Tags: tags,
Expiry: expiryDur,
TailnetId: tailnet.Id,
Ephemeral: ephemeral,
PreAuthorized: preAuthorized,
Tags: tags,
Expiry: expiryDur,
}
resp, err := client.CreateAuthKey(context.Background(), req)
resp, err := client.CreateAuthKey(context.Background(), connect.NewRequest(req))
if err != nil {
return err
@@ -81,7 +89,7 @@ func createAuthkeysCommand() *coral.Command {
fmt.Println("Generated new auth key")
fmt.Println("Be sure to copy your new key below. It won't be shown in full again.")
fmt.Println("")
fmt.Printf(" %s\n", resp.Value)
fmt.Printf(" %s\n", resp.Msg.Value)
fmt.Println("")
return nil
@@ -93,23 +101,23 @@ func createAuthkeysCommand() *coral.Command {
func deleteAuthKeyCommand() *coral.Command {
command := &coral.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, "")
command.Flags().Uint64Var(&authKeyId, "id", 0, "Auth Key ID")
command.RunE = func(command *coral.Command, args []string) error {
grpcClient, c, err := target.createGRPCClient()
grpcClient, err := target.createGRPCClient()
if err != nil {
return err
}
defer safeClose(c)
req := api.DeleteAuthKeyRequest{AuthKeyId: authKeyId}
if _, err := grpcClient.DeleteAuthKey(context.Background(), &req); err != nil {
if _, err := grpcClient.DeleteAuthKey(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -124,6 +132,7 @@ func deleteAuthKeyCommand() *coral.Command {
func listAuthkeysCommand() *coral.Command {
command := &coral.Command{
Use: "list",
Short: "List all auth keys for a given tailnet",
SilenceUsage: true,
}
@@ -132,15 +141,15 @@ func listAuthkeysCommand() *coral.Command {
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "")
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, c, err := target.createGRPCClient()
client, err := target.createGRPCClient()
if err != nil {
return err
}
defer safeClose(c)
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
@@ -148,13 +157,13 @@ func listAuthkeysCommand() *coral.Command {
}
req := &api.ListAuthKeysRequest{TailnetId: tailnet.Id}
resp, err := client.ListAuthKeys(context.Background(), req)
resp, err := client.ListAuthKeys(context.Background(), connect.NewRequest(req))
if err != nil {
return err
}
printAuthKeyTable(resp.AuthKeys...)
printAuthKeyTable(resp.Msg.AuthKeys...)
return nil
}
@@ -163,7 +172,7 @@ func listAuthkeysCommand() *coral.Command {
}
func printAuthKeyTable(authKeys ...*api.AuthKey) {
tbl := table.New("ID", "VALUE", "EPHEMERAL", "EXPIRED", "CREATED_AT", "EXPIRES_AT")
tbl := table.New("ID", "KEY", "EPHEMERAL", "EXPIRED", "EXPIRES_AT", "TAGS")
for _, authKey := range authKeys {
addAuthKeyToTable(tbl, authKey)
}
@@ -177,5 +186,5 @@ func addAuthKeyToTable(tbl table.Table, authKey *api.AuthKey) {
expiresAt = authKey.ExpiresAt.AsTime().Local().Format("2006-01-02 15:04:05")
expired = time.Now().After(authKey.ExpiresAt.AsTime())
}
tbl.AddRow(authKey.Id, fmt.Sprintf("%s...", authKey.Key), authKey.Ephemeral, expired, authKey.CreatedAt.AsTime().Local().Format("2006-01-02 15:04:05"), expiresAt)
tbl.AddRow(authKey.Id, fmt.Sprintf("%s...", authKey.Key), authKey.Ephemeral, expired, expiresAt, strings.Join(authKey.Tags, ","))
}
+92
View File
@@ -0,0 +1,92 @@
package cmd
import (
"errors"
"fmt"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/key"
"github.com/muesli/coral"
"gopkg.in/yaml.v2"
"path/filepath"
)
func configureCommand() *coral.Command {
command := &coral.Command{
Use: "configure",
Short: "Generate a simple config file to get started.",
SilenceUsage: true,
}
var domain string
var acme bool
var email string
var dataDir string
var certFile string
var keyFile string
command.Flags().StringVar(&domain, "domain", "", "Public domain name of your ionscale instance.")
command.Flags().StringVar(&dataDir, "data-dir", "/var/lib/ionscale", "")
command.Flags().BoolVar(&acme, "acme", false, "Get automatic certificate from Letsencrypt.org using ACME.")
command.Flags().StringVar(&email, "acme-email", "", "Email to receive updates from Letsencrypt.org.")
command.Flags().StringVar(&certFile, "cert-file", "", "Path to a TLS certificate file.")
command.Flags().StringVar(&keyFile, "key-file", "", "Path to a TLS key file.")
command.MarkFlagRequired("domain")
command.PreRunE = func(cmd *coral.Command, args []string) error {
if domain == "" {
return errors.New("required flag 'domain' is missing")
}
if acme && email == "" {
return errors.New("flag 'acme-email' is required when acme is enabled")
}
if !acme && (certFile == "" || keyFile == "") {
return errors.New("flags 'cert-file' and 'key-file' are required when acme is disabled")
}
return nil
}
command.RunE = func(command *coral.Command, args []string) error {
c := &config.Config{}
c.HttpListenAddr = "0.0.0.0:80"
c.HttpsListenAddr = "0.0.0.0:443"
c.MetricsListenAddr = "127.0.0.1:9090"
c.ServerUrl = fmt.Sprintf("https://%s", domain)
c.Keys = config.Keys{
ControlKey: key.NewServerKey().String(),
LegacyControlKey: key.NewServerKey().String(),
SystemAdminKey: key.NewServerKey().String(),
}
c.Tls = config.Tls{}
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
}
c.Database = config.Database{
Type: "sqlite",
Url: filepath.Join(dataDir, "ionscale.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)"),
}
configAsYaml, err := yaml.Marshal(c)
if err != nil {
return err
}
fmt.Println(string(configAsYaml))
return nil
}
return command
}
+150
View File
@@ -0,0 +1,150 @@
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"
"gopkg.in/yaml.v2"
"os"
"tailscale.com/tailcfg"
)
func systemCommand() *coral.Command {
command := &coral.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{
Use: "get-derp-map",
Short: "Get the 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{}))
if err != nil {
return err
}
var derpMap struct {
Regions map[int]*tailcfg.DERPRegion
}
if err := json.Unmarshal(resp.Msg.Value, &derpMap); err != nil {
return err
}
if asJson {
marshal, err := json.MarshalIndent(derpMap, "", " ")
if err != nil {
return err
}
fmt.Println(string(marshal))
} else {
marshal, err := yaml.Marshal(derpMap)
if err != nil {
return err
}
fmt.Println(string(marshal))
}
return nil
}
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
}
+169
View File
@@ -0,0 +1,169 @@
package cmd
import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"os"
"strings"
"text/tabwriter"
)
func getDNSConfigCommand() *coral.Command {
command := &coral.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))
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)
}
}
return nil
}
return command
}
func setDNSConfigCommand() *coral.Command {
command := &coral.Command{
Use: "set-dns",
Short: "Set DNS config",
SilenceUsage: true,
}
var nameservers []string
var magicDNS bool
var overrideLocalDNS bool
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.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(&overrideLocalDNS, "override-local-dns", "", false, "When enabled, connected clients ignore local DNS settings and always use the nameservers specified for this 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
}
var globalNameservers []string
var routes = make(map[string]*api.Routes)
for _, n := range nameservers {
split := strings.Split(n, ":")
if len(split) == 2 {
r, ok := routes[split[0]]
if ok {
r.Routes = append(r.Routes, split[1])
} else {
routes[split[0]] = &api.Routes{Routes: []string{split[1]}}
}
} else {
globalNameservers = append(globalNameservers, n)
}
}
req := api.SetDNSConfigRequest{
TailnetId: tailnet.Id,
Config: &api.DNSConfig{
MagicDns: magicDNS,
OverrideLocalDns: overrideLocalDNS,
Nameservers: globalNameservers,
Routes: routes,
},
}
resp, err := client.SetDNSConfig(context.Background(), connect.NewRequest(&req))
if err != nil {
return err
}
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))
}
}
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, ","))
return nil
}
return command
}
+33 -14
View File
@@ -3,31 +3,50 @@ package cmd
import (
"context"
"fmt"
"github.com/jsiebens/ionscale/pkg/gen/api"
"io"
"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 findTailnet(client api.IonscaleClient, tailnet string, tailnetID uint64) (*api.Tailnet, error) {
if tailnetID == 0 && tailnet == "" {
return nil, fmt.Errorf("requested tailnet not found or you are not authorized for this tailnet")
func checkRequiredTailnetAndTailnetIdFlags(cmd *coral.Command, args []string) error {
savedTailnetID, err := ionscale.TailnetFromFile()
if err != nil {
return err
}
tailnets, err := client.ListTailnets(context.Background(), &api.ListTailnetRequest{})
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
}
for _, t := range tailnets.Tailnet {
if t.Id == tailnetID || t.Name == tailnet {
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")
}
func safeClose(c io.Closer) {
if c != nil {
_ = c.Close()
}
}
+175
View File
@@ -0,0 +1,175 @@
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"
"os"
)
func getIAMPolicyCommand() *coral.Command {
command := &coral.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()
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))
return nil
}
return command
}
func editIAMPolicyCommand() *coral.Command {
command := &coral.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 {
edit := editor.NewDefaultEditor([]string{"IONSCALE_EDITOR", "EDITOR"})
client, err := target.createGRPCClient()
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))
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}))
if err != nil {
return err
}
fmt.Println("IAM policy updated successfully")
return nil
}
return command
}
func setIAMPolicyCommand() *coral.Command {
command := &coral.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)
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}))
if err != nil {
return err
}
fmt.Println("IAM policy updated successfully")
return nil
}
return command
}
+30
View File
@@ -0,0 +1,30 @@
package cmd
import (
"fmt"
"github.com/jsiebens/ionscale/internal/key"
"github.com/muesli/coral"
)
func keyCommand() *coral.Command {
command := &coral.Command{
Use: "genkey",
SilenceUsage: true,
}
var disableNewLine bool
command.Flags().BoolVarP(&disableNewLine, "no-newline", "n", false, "do not output a trailing newline")
command.RunE = func(command *coral.Command, args []string) error {
serverKey := key.NewServerKey()
if disableNewLine {
fmt.Print(serverKey.String())
} else {
fmt.Println(serverKey.String())
}
return nil
}
return command
}
+474 -13
View File
@@ -3,21 +3,149 @@ package cmd
import (
"context"
"fmt"
"github.com/jsiebens/ionscale/pkg/gen/api"
"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"
"inet.af/netaddr"
"os"
"strings"
"text/tabwriter"
)
func machineCommands() *coral.Command {
command := &coral.Command{
Use: "machines",
Aliases: []string{"machine"},
Short: "Manage ionscale machines",
SilenceUsage: true,
}
command.AddCommand(getMachineCommand())
command.AddCommand(deleteMachineCommand())
command.AddCommand(expireMachineCommand())
command.AddCommand(listMachinesCommand())
command.AddCommand(getMachineRoutesCommand())
command.AddCommand(enableMachineRoutesCommand())
command.AddCommand(disableMachineRoutesCommand())
command.AddCommand(enableMachineKeyExpiryCommand())
command.AddCommand(enableExitNodeCommand())
command.AddCommand(disableExitNodeCommand())
command.AddCommand(disableMachineKeyExpiryCommand())
command.AddCommand(authorizeMachineCommand())
return command
}
func getMachineCommand() *coral.Command {
command := &coral.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
}
req := api.GetMachineRequest{MachineId: machineID}
resp, err := client.GetMachine(context.Background(), connect.NewRequest(&req))
if err != nil {
return err
}
m := resp.Msg.Machine
var lastSeen = "N/A"
var expiresAt = "No expiry"
if m.LastSeen != nil && !m.LastSeen.AsTime().IsZero() {
if mom, err := goment.New(m.LastSeen.AsTime()); err == nil {
lastSeen = mom.FromNow()
}
}
if !m.KeyExpiryDisabled && m.ExpiresAt != nil && !m.ExpiresAt.AsTime().IsZero() {
if mom, err := goment.New(m.ExpiresAt.AsTime()); !m.ExpiresAt.AsTime().IsZero() && err == nil {
expiresAt = mom.FromNow()
}
}
// initialize tabwriter
w := new(tabwriter.Writer)
// minwidth, tabwidth, padding, padchar, flags
w.Init(os.Stdout, 8, 8, 0, '\t', 0)
defer w.Flush()
fmt.Fprintf(w, "%s\t%d\n", "ID", m.Id)
fmt.Fprintf(w, "%s\t%s\n", "Machine name", m.Name)
fmt.Fprintf(w, "%s\t%s\n", "Creator", m.User.Name)
fmt.Fprintf(w, "%s\t%s\n", "OS", m.Os)
fmt.Fprintf(w, "%s\t%s\n", "Tailscale version", m.ClientVersion)
fmt.Fprintf(w, "%s\t%s\n", "Tailscale IPv4", m.Ipv4)
fmt.Fprintf(w, "%s\t%s\n", "Tailscale IPv6", m.Ipv6)
fmt.Fprintf(w, "%s\t%s\n", "Last seen", lastSeen)
fmt.Fprintf(w, "%s\t%v\n", "Ephemeral", m.Ephemeral)
if !m.Authorized {
fmt.Fprintf(w, "%s\t%v\n", "Authorized", m.Authorized)
}
fmt.Fprintf(w, "%s\t%s\n", "Key expiry", expiresAt)
for i, t := range m.Tags {
if i == 0 {
fmt.Fprintf(w, "%s\t%s\n", "ACL tags", t)
} else {
fmt.Fprintf(w, "%s\t%s\n", "", t)
}
}
for i, e := range m.ClientConnectivity.Endpoints {
if i == 0 {
fmt.Fprintf(w, "%s\t%s\n", "Endpoints", e)
} else {
fmt.Fprintf(w, "%s\t%s\n", "", e)
}
}
for i, t := range m.AdvertisedRoutes {
if i == 0 {
fmt.Fprintf(w, "%s\t%s\n", "Advertised routes", t)
} else {
fmt.Fprintf(w, "%s\t%s\n", "", t)
}
}
for i, t := range m.EnabledRoutes {
if i == 0 {
fmt.Fprintf(w, "%s\t%s\n", "Enabled routes", t)
} else {
fmt.Fprintf(w, "%s\t%s\n", "", t)
}
}
if m.AdvertisedExitNode {
if m.EnabledExitNode {
fmt.Fprintf(w, "%s\t%s\n", "Exit node", "enabled")
} else {
fmt.Fprintf(w, "%s\t%s\n", "Exit node", "disabled")
}
} else {
fmt.Fprintf(w, "%s\t%s\n", "Exit node", "no")
}
return nil
}
return command
}
@@ -32,17 +160,18 @@ func deleteMachineCommand() *coral.Command {
var machineID uint64
var target = Target{}
target.prepareCommand(command)
command.Flags().Uint64Var(&machineID, "machine-id", 0, "")
command.Flags().Uint64Var(&machineID, "machine-id", 0, "Machine ID.")
_ = command.MarkFlagRequired("machine-id")
command.RunE = func(command *coral.Command, args []string) error {
client, c, err := target.createGRPCClient()
client, err := target.createGRPCClient()
if err != nil {
return err
}
defer safeClose(c)
req := api.DeleteMachineRequest{MachineId: machineID}
if _, err := client.DeleteMachine(context.Background(), &req); err != nil {
if _, err := client.DeleteMachine(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
@@ -54,6 +183,72 @@ func deleteMachineCommand() *coral.Command {
return command
}
func expireMachineCommand() *coral.Command {
command := &coral.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
}
req := api.ExpireMachineRequest{MachineId: machineID}
if _, err := client.ExpireMachine(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
fmt.Println("Machine key expired.")
return nil
}
return command
}
func authorizeMachineCommand() *coral.Command {
command := &coral.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
}
req := api.AuthorizeMachineRequest{MachineId: machineID}
if _, err := client.AuthorizeMachine(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
fmt.Println("Machine authorized.")
return nil
}
return command
}
func listMachinesCommand() *coral.Command {
command := &coral.Command{
Use: "list",
@@ -66,15 +261,15 @@ func listMachinesCommand() *coral.Command {
var target = Target{}
target.prepareCommand(command)
command.Flags().StringVar(&tailnetName, "tailnet", "", "")
command.Flags().Uint64Var(&tailnetID, "tailnet-id", 0, "")
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, c, err := target.createGRPCClient()
client, err := target.createGRPCClient()
if err != nil {
return err
}
defer safeClose(c)
tailnet, err := findTailnet(client, tailnetName, tailnetID)
if err != nil {
@@ -82,14 +277,14 @@ func listMachinesCommand() *coral.Command {
}
req := api.ListMachinesRequest{TailnetId: tailnet.Id}
resp, err := client.ListMachines(context.Background(), &req)
resp, err := client.ListMachines(context.Background(), connect.NewRequest(&req))
if err != nil {
return err
}
tbl := table.New("ID", "TAILNET", "NAME", "IPv4", "IPv6", "EPHEMERAL", "LAST_SEEN", "USER")
for _, m := range resp.Machines {
tbl := table.New("ID", "TAILNET", "NAME", "IPv4", "IPv6", "AUTHORIZED", "EPHEMERAL", "LAST_SEEN", "TAGS")
for _, m := range resp.Msg.Machines {
var lastSeen = "N/A"
if m.Connected {
lastSeen = "Connected"
@@ -99,7 +294,7 @@ func listMachinesCommand() *coral.Command {
lastSeen = mom.FromNow()
}
}
tbl.AddRow(m.Id, m.Tailnet.Name, m.Name, m.Ipv4, m.Ipv6, m.Ephemeral, lastSeen, m.User.Name)
tbl.AddRow(m.Id, m.Tailnet.Name, m.Name, m.Ipv4, m.Ipv6, m.Authorized, m.Ephemeral, lastSeen, strings.Join(m.Tags, ","))
}
tbl.Print()
@@ -108,3 +303,269 @@ func listMachinesCommand() *coral.Command {
return command
}
func getMachineRoutesCommand() *coral.Command {
command := &coral.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
}
req := api.GetMachineRoutesRequest{MachineId: machineID}
resp, err := grpcClient.GetMachineRoutes(context.Background(), connect.NewRequest(&req))
if err != nil {
return err
}
printMachinesRoutesResponse(resp.Msg.Routes)
return nil
}
return command
}
func enableMachineRoutesCommand() *coral.Command {
command := &coral.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
}
for _, r := range routes {
if _, err := netaddr.ParseIPPrefix(r); err != nil {
return err
}
}
req := api.EnableMachineRoutesRequest{MachineId: machineID, Routes: routes, Replace: replace}
resp, err := client.EnableMachineRoutes(context.Background(), connect.NewRequest(&req))
if err != nil {
return err
}
printMachinesRoutesResponse(resp.Msg.Routes)
return nil
}
return command
}
func disableMachineRoutesCommand() *coral.Command {
command := &coral.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
}
for _, r := range routes {
if _, err := netaddr.ParseIPPrefix(r); err != nil {
return err
}
}
req := api.DisableMachineRoutesRequest{MachineId: machineID, Routes: routes}
resp, err := client.DisableMachineRoutes(context.Background(), connect.NewRequest(&req))
if err != nil {
return err
}
printMachinesRoutesResponse(resp.Msg.Routes)
return nil
}
return command
}
func enableExitNodeCommand() *coral.Command {
command := &coral.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
}
req := api.EnableExitNodeRequest{MachineId: machineID}
resp, err := client.EnableExitNode(context.Background(), connect.NewRequest(&req))
if err != nil {
return err
}
printMachinesRoutesResponse(resp.Msg.Routes)
return nil
}
return command
}
func disableExitNodeCommand() *coral.Command {
command := &coral.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
}
req := api.DisableExitNodeRequest{MachineId: machineID}
resp, err := client.DisableExitNode(context.Background(), connect.NewRequest(&req))
if err != nil {
return err
}
printMachinesRoutesResponse(resp.Msg.Routes)
return nil
}
return command
}
func enableMachineKeyExpiryCommand() *coral.Command {
command := &coral.Command{
Use: "enable-key-expiry",
Short: "Enable machine key expiry",
SilenceUsage: true,
}
return configureSetMachineKeyExpiryCommand(command, false)
}
func disableMachineKeyExpiryCommand() *coral.Command {
command := &coral.Command{
Use: "disable-key-expiry",
Short: "Disable machine key expiry",
SilenceUsage: true,
}
return configureSetMachineKeyExpiryCommand(command, true)
}
func configureSetMachineKeyExpiryCommand(command *coral.Command, v bool) *coral.Command {
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))
if err != nil {
return err
}
return nil
}
return command
}
func printMachinesRoutesResponse(msg *api.MachineRoutes) {
w := new(tabwriter.Writer)
w.Init(os.Stdout, 8, 8, 0, '\t', 0)
defer w.Flush()
for i, t := range msg.AdvertisedRoutes {
if i == 0 {
fmt.Fprintf(w, "%s\t%s\n", "Advertised routes", t)
} else {
fmt.Fprintf(w, "%s\t%s\n", "", t)
}
}
for i, t := range msg.EnabledRoutes {
if i == 0 {
fmt.Fprintf(w, "%s\t%s\n", "Enabled routes", t)
} else {
fmt.Fprintf(w, "%s\t%s\n", "", t)
}
}
if msg.AdvertisedExitNode {
if msg.EnabledExitNode {
fmt.Fprintf(w, "%s\t%s\n", "Exit node", "enabled")
} else {
fmt.Fprintf(w, "%s\t%s\n", "Exit node", "disabled")
}
} else {
fmt.Fprintf(w, "%s\t%s\n", "Exit node", "no")
}
}
+5
View File
@@ -6,11 +6,16 @@ import (
func Command() *coral.Command {
rootCmd := rootCommand()
rootCmd.AddCommand(configureCommand())
rootCmd.AddCommand(keyCommand())
rootCmd.AddCommand(authCommand())
rootCmd.AddCommand(serverCommand())
rootCmd.AddCommand(versionCommand())
rootCmd.AddCommand(tailnetCommand())
rootCmd.AddCommand(authkeysCommand())
rootCmd.AddCommand(machineCommands())
rootCmd.AddCommand(userCommands())
rootCmd.AddCommand(systemCommand())
return rootCmd
}
+1 -1
View File
@@ -15,7 +15,7 @@ func serverCommand() *coral.Command {
var configFile string
command.Flags().StringVarP(&configFile, "config", "c", "ionscale.yaml", "Path to the configuration file.")
command.Flags().StringVarP(&configFile, "config", "c", "", "Path to the configuration file.")
command.RunE = func(command *coral.Command, args []string) error {
+607 -17
View File
@@ -2,20 +2,48 @@ package cmd
import (
"context"
"github.com/jsiebens/ionscale/pkg/gen/api"
"encoding/json"
"fmt"
"github.com/bufbuild/connect-go"
idomain "github.com/jsiebens/ionscale/internal/domain"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
"github.com/rodaine/table"
"gopkg.in/yaml.v3"
"os"
"strings"
"tailscale.com/tailcfg"
)
func tailnetCommand() *coral.Command {
command := &coral.Command{
Use: "tailnets",
Short: "Manage ionscale tailnets",
Long: "This command allows operations on ionscale tailnet resources.",
Use: "tailnets",
Aliases: []string{"tailnet"},
Short: "Manage ionscale tailnets",
}
command.AddCommand(listTailnetsCommand())
command.AddCommand(createTailnetsCommand())
command.AddCommand(deleteTailnetCommand())
command.AddCommand(getDNSConfigCommand())
command.AddCommand(setDNSConfigCommand())
command.AddCommand(getACLConfigCommand())
command.AddCommand(setACLConfigCommand())
command.AddCommand(editACLConfigCommand())
command.AddCommand(getIAMPolicyCommand())
command.AddCommand(setIAMPolicyCommand())
command.AddCommand(editIAMPolicyCommand())
command.AddCommand(enableServiceCollectionCommand())
command.AddCommand(disableServiceCollectionCommand())
command.AddCommand(enableFileSharingCommand())
command.AddCommand(disableFileSharingCommand())
command.AddCommand(enableSSHCommand())
command.AddCommand(disableSSHCommand())
command.AddCommand(enableMachineAuthorizationCommand())
command.AddCommand(disableMachineAuthorizationCommand())
command.AddCommand(getDERPMap())
command.AddCommand(setDERPMap())
command.AddCommand(resetDERPMap())
return command
}
@@ -23,8 +51,7 @@ func tailnetCommand() *coral.Command {
func listTailnetsCommand() *coral.Command {
command := &coral.Command{
Use: "list",
Short: "List tailnets",
Long: `List tailnets in this ionscale instance.`,
Short: "List available Tailnets",
SilenceUsage: true,
}
@@ -33,20 +60,19 @@ func listTailnetsCommand() *coral.Command {
command.RunE = func(command *coral.Command, args []string) error {
client, c, err := target.createGRPCClient()
client, err := target.createGRPCClient()
if err != nil {
return err
}
defer safeClose(c)
resp, err := client.ListTailnets(context.Background(), &api.ListTailnetRequest{})
resp, err := client.ListTailnets(context.Background(), connect.NewRequest(&api.ListTailnetsRequest{}))
if err != nil {
return err
}
tbl := table.New("ID", "NAME")
for _, tailnet := range resp.Tailnet {
for _, tailnet := range resp.Msg.Tailnet {
tbl.AddRow(tailnet.Id, tailnet.Name)
}
tbl.Print()
@@ -60,34 +86,67 @@ func listTailnetsCommand() *coral.Command {
func createTailnetsCommand() *coral.Command {
command := &coral.Command{
Use: "create",
Short: "Create a new tailnet",
Long: `List tailnets in this ionscale instance.`,
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.MarkFlagRequired("name")
command.Flags().StringVar(&domain, "domain", "", "")
command.Flags().StringVar(&email, "email", "", "")
command.PreRunE = func(cmd *coral.Command, args []string) error {
if name == "" {
return fmt.Errorf("flag --name is required")
}
if domain != "" && email != "" {
return fmt.Errorf("flags --email and --domain are mutually exclusive")
}
return nil
}
command.RunE = func(command *coral.Command, args []string) error {
client, c, err := target.createGRPCClient()
var iamPolicy = api.IAMPolicy{}
if len(domain) != 0 {
domainToLower := strings.ToLower(domain)
iamPolicy = api.IAMPolicy{
Filters: []string{fmt.Sprintf("domain == %s", domainToLower)},
}
}
if len(email) != 0 {
emailToLower := strings.ToLower(email)
iamPolicy = api.IAMPolicy{
Emails: []string{emailToLower},
Roles: map[string]string{
emailToLower: string(idomain.UserRoleAdmin),
},
}
}
client, err := target.createGRPCClient()
if err != nil {
return err
}
defer safeClose(c)
resp, err := client.CreateTailnet(context.Background(), &api.CreateTailnetRequest{Name: name})
resp, err := client.CreateTailnet(context.Background(), connect.NewRequest(&api.CreateTailnetRequest{
Name: name,
IamPolicy: &iamPolicy,
}))
if err != nil {
return err
}
tbl := table.New("ID", "NAME")
tbl.AddRow(resp.Tailnet.Id, resp.Tailnet.Name)
tbl.AddRow(resp.Msg.Tailnet.Id, resp.Msg.Tailnet.Name)
tbl.Print()
return nil
@@ -95,3 +154,534 @@ func createTailnetsCommand() *coral.Command {
return command
}
func deleteTailnetCommand() *coral.Command {
command := &coral.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}))
if err != nil {
return err
}
fmt.Println("Tailnet deleted.")
return nil
}
return command
}
func getDERPMap() *coral.Command {
command := &coral.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}))
if err != nil {
return err
}
var derpMap struct {
Regions map[int]*tailcfg.DERPRegion
}
if err := json.Unmarshal(resp.Msg.Value, &derpMap); err != nil {
return err
}
if asJson {
marshal, err := json.MarshalIndent(derpMap, "", " ")
if err != nil {
return err
}
fmt.Println(string(marshal))
} else {
marshal, err := yaml.Marshal(derpMap)
if err != nil {
return err
}
fmt.Println(string(marshal))
}
return nil
}
return command
}
func setDERPMap() *coral.Command {
command := &coral.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
}
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}))
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 resetDERPMap() *coral.Command {
command := &coral.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 {
return err
}
fmt.Println("DERP Map updated successfully")
return nil
}
return command
}
func enableFileSharingCommand() *coral.Command {
command := &coral.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
}
req := api.EnableFileSharingRequest{
TailnetId: tailnet.Id,
}
if _, err := client.EnableFileSharing(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
return nil
}
return command
}
func disableFileSharingCommand() *coral.Command {
command := &coral.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
}
req := api.DisableFileSharingRequest{
TailnetId: tailnet.Id,
}
if _, err := client.DisableFileSharing(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
return nil
}
return command
}
func enableServiceCollectionCommand() *coral.Command {
command := &coral.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
}
req := api.EnableServiceCollectionRequest{
TailnetId: tailnet.Id,
}
if _, err := client.EnableServiceCollection(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
return nil
}
return command
}
func disableServiceCollectionCommand() *coral.Command {
command := &coral.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
}
req := api.DisableServiceCollectionRequest{
TailnetId: tailnet.Id,
}
if _, err := client.DisableServiceCollection(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
return nil
}
return command
}
func enableSSHCommand() *coral.Command {
command := &coral.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
}
req := api.EnableSSHRequest{
TailnetId: tailnet.Id,
}
if _, err := client.EnableSSH(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
return nil
}
return command
}
func disableSSHCommand() *coral.Command {
command := &coral.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
}
req := api.DisableSSHRequest{
TailnetId: tailnet.Id,
}
if _, err := client.DisableSSH(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
return nil
}
return command
}
func enableMachineAuthorizationCommand() *coral.Command {
command := &coral.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
}
req := api.EnableMachineAuthorizationRequest{
TailnetId: tailnet.Id,
}
if _, err := client.EnableMachineAuthorization(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
return nil
}
return command
}
func disableMachineAuthorizationCommand() *coral.Command {
command := &coral.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
}
req := api.DisableMachineAuthorizationRequest{
TailnetId: tailnet.Id,
}
if _, err := client.DisableMachineAuthorization(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
return nil
}
return command
}
+6 -7
View File
@@ -3,13 +3,12 @@ package cmd
import (
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/pkg/client/ionscale"
"github.com/jsiebens/ionscale/pkg/gen/api"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1/ionscalev1connect"
"github.com/muesli/coral"
"io"
)
const (
ionscaleSystemAdminKey = "IONSCALE_ADMIN_KEY"
ionscaleSystemAdminKey = "IONSCALE_SYSTEM_ADMIN_KEY"
ionscaleAddr = "IONSCALE_ADDR"
ionscaleInsecureSkipVerify = "IONSCALE_SKIP_VERIFY"
)
@@ -23,17 +22,17 @@ type Target struct {
func (t *Target) prepareCommand(cmd *coral.Command) {
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, "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.")
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.IonscaleClient, io.Closer, error) {
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, nil, err
return nil, err
}
return ionscale.NewClient(auth, addr, skipVerify)
@@ -43,7 +42,7 @@ func (t *Target) getAddr() string {
if len(t.addr) != 0 {
return t.addr
}
return config.GetString(ionscaleAddr, "https://localhost:8000")
return config.GetString(ionscaleAddr, "https://localhost:8443")
}
func (t *Target) getInsecureSkipVerify() bool {
+103
View File
@@ -0,0 +1,103 @@
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"
)
func userCommands() *coral.Command {
command := &coral.Command{
Use: "users",
Aliases: []string{"user"},
Short: "Manage ionscale users",
SilenceUsage: true,
}
command.AddCommand(listUsersCommand())
command.AddCommand(deleteUserCommand())
return command
}
func listUsersCommand() *coral.Command {
command := &coral.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))
if err != nil {
return err
}
tbl := table.New("ID", "USER", "ROLE")
for _, m := range resp.Msg.Users {
tbl.AddRow(m.Id, m.Name, m.Role)
}
tbl.Print()
return nil
}
return command
}
func deleteUserCommand() *coral.Command {
command := &coral.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
}
req := api.DeleteUserRequest{UserId: userID}
if _, err := client.DeleteUser(context.Background(), connect.NewRequest(&req)); err != nil {
return err
}
fmt.Println("User deleted.")
return nil
}
return command
}
+5 -5
View File
@@ -3,8 +3,9 @@ package cmd
import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/ionscale/internal/version"
"github.com/jsiebens/ionscale/pkg/gen/api"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/muesli/coral"
)
@@ -26,7 +27,7 @@ Client:
Git Revision: %s
`, clientVersion, clientRevision)
client, c, err := target.createGRPCClient()
client, err := target.createGRPCClient()
if err != nil {
fmt.Printf(`
Server:
@@ -34,9 +35,8 @@ Server:
`, err)
return
}
defer safeClose(c)
resp, err := client.GetVersion(context.Background(), &api.GetVersionRequest{})
resp, err := client.GetVersion(context.Background(), connect.NewRequest(&api.GetVersionRequest{}))
if err != nil {
fmt.Printf(`
Server:
@@ -50,7 +50,7 @@ Server:
Addr: %s
Version: %s
Git Revision: %s
`, target.getAddr(), resp.Version, resp.Revision)
`, target.getAddr(), resp.Msg.Version, resp.Msg.Revision)
}
+180 -76
View File
@@ -1,118 +1,211 @@
package config
import (
"encoding/base64"
"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"
"io/ioutil"
"os"
"path/filepath"
"strings"
"tailscale.com/types/key"
tkey "tailscale.com/types/key"
"time"
)
const (
defaultKeepAliveInterval = 1 * time.Minute
defaultMagicDNSSuffix = "ionscale.net"
)
var (
keepAliveInterval = defaultKeepAliveInterval
magicDNSSuffix = defaultMagicDNSSuffix
dnsProviderConfigured = false
)
func KeepAliveInterval() time.Duration {
return keepAliveInterval
}
func MagicDNSSuffix() string {
return magicDNSSuffix
}
func DNSProviderConfigured() bool {
return dnsProviderConfigured
}
func LoadConfig(path string) (*Config, error) {
config := defaultConfig()
cfg := defaultConfig()
if len(path) != 0 {
expandedPath, err := homedir.Expand(path)
if err != nil {
return nil, err
}
b, err := ioutil.ReadFile(expandedPath)
absPath, err := filepath.Abs(expandedPath)
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(b, config); err != nil {
b, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(b, cfg); err != nil {
return nil, err
}
}
return config, nil
}
envCfgB64 := os.Getenv("IONSCALE_CONFIG_BASE64")
if len(envCfgB64) != 0 {
b, err := base64.RawStdEncoding.DecodeString(envCfgB64)
if err != nil {
return nil, err
}
const (
listenAddrKey = "IONSCALE_LISTEN_ADDR"
serverUrlKey = "IONSCALE_SERVER_URL"
keysSystemAdminKeyKey = "IONSCALE_SYSTEM_ADMIN_KEY"
keysControlKeyKey = "IONSCALE_CONTROL_KEY"
keysLegacyControlKeyKey = "IONSCALE_LEGACY_CONTROL_KEY"
databaseUrlKey = "IONSCALE_DB_URL"
tlsDisableKey = "IONSCALE_TLS_DISABLE"
tlsCertFileKey = "IONSCALE_TLS_CERT_FILE"
tlsKeyFileKey = "IONSCALE_TLS_KEY_FILE"
metricsListenAddrKey = "IONSCALE_METRICS_LISTEN_ADDR"
loggingLevelKey = "IONSCALE_LOGGING_LEVEL"
loggingFormatKey = "IONSCALE_LOGGING_FORMAT"
loggingFileKey = "IONSCALE_LOGGING_FILE"
)
// 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
magicDNSSuffix = cfg.DNS.MagicDNSSuffix
if cfg.DNS.Provider.Zone != "" {
dnsProviderConfigured = true
}
return cfg, nil
}
func defaultConfig() *Config {
return &Config{
ListenAddr: GetString(listenAddrKey, ":8000"),
ServerUrl: GetString(serverUrlKey, "https://localhost:8000"),
Keys: Keys{
SystemAdminKey: GetString(keysSystemAdminKeyKey, ""),
ControlKey: GetString(keysControlKeyKey, ""),
LegacyControlKey: GetString(keysLegacyControlKeyKey, ""),
},
HttpListenAddr: ":8080",
HttpsListenAddr: ":8443",
MetricsListenAddr: ":9091",
ServerUrl: "https://localhost:8843",
Database: Database{
Url: GetString(databaseUrlKey, "ionscale.db"),
Type: "sqlite",
Url: "./ionscale.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)",
},
Tls: Tls{
Disable: GetBool(tlsDisableKey, false),
CertFile: GetString(tlsCertFileKey, ""),
KeyFile: GetString(tlsKeyFileKey, ""),
Disable: false,
ForceHttps: true,
AcmeEnabled: false,
AcmeCA: certmagic.LetsEncryptProductionCA,
AcmePath: "./acme",
},
PollNet: PollNet{
KeepAliveInterval: defaultKeepAliveInterval,
},
DNS: DNS{
MagicDNSSuffix: defaultMagicDNSSuffix,
},
Metrics: Metrics{ListenAddr: GetString(metricsListenAddrKey, ":8001")},
Logging: Logging{
Level: GetString(loggingLevelKey, "info"),
Format: GetString(loggingFormatKey, ""),
File: GetString(loggingFileKey, ""),
Level: "info",
},
}
}
type ServerKeys struct {
SystemAdminKey key.MachinePrivate
ControlKey key.MachinePrivate
LegacyControlKey key.MachinePrivate
SystemAdminKey *key.ServerPrivate
ControlKey tkey.MachinePrivate
LegacyControlKey tkey.MachinePrivate
}
type Config struct {
ListenAddr string `yaml:"listen_addr"`
ServerUrl string `yaml:"server_url"`
Tls Tls `yaml:"tls"`
Metrics Metrics `yaml:"metrics"`
Logging Logging `yaml:"logging"`
Keys Keys `yaml:"keys"`
Database Database `yaml:"database"`
}
type Metrics struct {
ListenAddr string `yaml:"listen_addr"`
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_"`
}
type Tls struct {
Disable bool `yaml:"disable"`
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
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"`
}
type PollNet struct {
KeepAliveInterval time.Duration `yaml:"keep_alive_interval" env:"KEEP_ALIVE_INTERVAL"`
}
type Logging struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
File string `yaml:"file"`
Level string `yaml:"level,omitempty" env:"LEVEL"`
Format string `yaml:"format,omitempty" env:"FORMAT"`
File string `yaml:"file,omitempty" env:"FILE"`
}
type Database struct {
Url string `yaml:"url"`
Type string `yaml:"type,omitempty" env:"TYPE"`
Url string `yaml:"url,omitempty" env:"URL"`
}
type Keys struct {
SystemAdminKey string `yaml:"system_admin_key"`
ControlKey string `yaml:"control_key"`
LegacyControlKey string `yaml:"legacy_control_key"`
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"`
}
type Auth struct {
Provider AuthProvider `yaml:"provider,omitempty" envPrefix:"PROVIDER_"`
SystemAdminPolicy SystemAdminPolicy `yaml:"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"`
}
type DNS struct {
MagicDNSSuffix string `yaml:"magic_dns_suffix"`
Provider DNSProvider `yaml:"provider,omitempty"`
}
type DNSProvider struct {
Name string `yaml:"name"`
Zone string `yaml:"zone"`
Configuration map[string]string `yaml:"config"`
}
type SystemAdminPolicy struct {
Subs []string `yaml:"subs,omitempty"`
Emails []string `yaml:"emails,omitempty"`
Filters []string `yaml:"filters,omitempty"`
}
func (c *Config) CreateUrl(format string, a ...interface{}) string {
@@ -120,25 +213,36 @@ func (c *Config) CreateUrl(format string, a ...interface{}) string {
return strings.TrimSuffix(c.ServerUrl, "/") + "/" + strings.TrimPrefix(path, "/")
}
func (c *Config) ReadServerKeys() (*ServerKeys, error) {
systemAdminKey, err := util.ParseMachinePrivateKey(c.Keys.SystemAdminKey)
if err != nil {
return nil, fmt.Errorf("error reading system admin key: %v", err)
func (c *Config) ReadServerKeys(defaultKeys *domain.ControlKeys) (*ServerKeys, error) {
keys := &ServerKeys{
ControlKey: defaultKeys.ControlKey,
LegacyControlKey: defaultKeys.LegacyControlKey,
}
controlKey, err := util.ParseMachinePrivateKey(c.Keys.ControlKey)
if err != nil {
return nil, fmt.Errorf("error reading control key: %v", err)
if len(c.Keys.SystemAdminKey) != 0 {
systemAdminKey, err := key.ParsePrivateKey(c.Keys.SystemAdminKey)
if err != nil {
return nil, fmt.Errorf("error reading system admin key: %v", err)
}
keys.SystemAdminKey = systemAdminKey
}
legacyControlKey, err := util.ParseMachinePrivateKey(c.Keys.LegacyControlKey)
if err != nil {
return nil, fmt.Errorf("error reading legacy control key: %v", err)
if len(c.Keys.ControlKey) != 0 {
controlKey, err := util.ParseMachinePrivateKey(c.Keys.ControlKey)
if err != nil {
return nil, fmt.Errorf("error reading control key: %v", err)
}
keys.ControlKey = *controlKey
}
return &ServerKeys{
SystemAdminKey: *systemAdminKey,
ControlKey: *controlKey,
LegacyControlKey: *legacyControlKey,
}, nil
if len(c.Keys.LegacyControlKey) != 0 {
legacyControlKey, err := util.ParseMachinePrivateKey(c.Keys.LegacyControlKey)
if err != nil {
return nil, fmt.Errorf("error reading legacy control key: %v", err)
}
keys.LegacyControlKey = *legacyControlKey
}
return keys, nil
}
@@ -1,8 +1,7 @@
package handlers
package core
import (
"context"
"github.com/jsiebens/ionscale/internal/broker"
"github.com/jsiebens/ionscale/internal/domain"
"time"
)
@@ -12,26 +11,28 @@ const (
inactivityTimeout = 30 * time.Minute
)
func NewReaper(brokers *broker.BrokerPool, repository domain.Repository) *Reaper {
return &Reaper{
brokers: brokers,
repository: repository,
func StartReaper(repository domain.Repository, sessionManager PollMapSessionManager) {
r := &reaper{
sessionManager: sessionManager,
repository: repository,
}
go r.start()
}
type Reaper struct {
brokers *broker.BrokerPool
repository domain.Repository
type reaper struct {
sessionManager PollMapSessionManager
repository domain.Repository
}
func (r *Reaper) Start() {
func (r *reaper) start() {
t := time.NewTicker(ticker)
for range t.C {
r.reapInactiveEphemeralNodes()
}
}
func (r *Reaper) reapInactiveEphemeralNodes() {
func (r *reaper) reapInactiveEphemeralNodes() {
ctx := context.Background()
now := time.Now().UTC()
@@ -54,8 +55,8 @@ func (r *Reaper) reapInactiveEphemeralNodes() {
}
if len(removedNodes) != 0 {
for i, p := range removedNodes {
r.brokers.Get(i).SignalPeersRemoved(p)
for i, _ := range removedNodes {
r.sessionManager.NotifyAll(i)
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package core
import (
"sync"
"time"
)
type Ping struct{}
type PollMapSessionManager interface {
Register(tailnetID uint64, machineID uint64, ch chan *Ping)
Deregister(tailnetID uint64, machineID uint64)
HasSession(tailnetID uint64, machineID uint64) bool
NotifyAll(tailnetID uint64)
}
func NewPollMapSessionManager() PollMapSessionManager {
return &pollMapSessionManager{
data: map[uint64]map[uint64]chan *Ping{},
timers: map[uint64]*time.Timer{},
}
}
type pollMapSessionManager struct {
sync.RWMutex
data map[uint64]map[uint64]chan *Ping
timers map[uint64]*time.Timer
}
func (n *pollMapSessionManager) Register(tailnetID uint64, 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
}
t, ok := n.timers[machineID]
if ok {
t.Stop()
delete(n.timers, machineID)
}
}
func (n *pollMapSessionManager) Deregister(tailnetID uint64, machineID uint64) {
n.Lock()
defer n.Unlock()
if ss := n.data[tailnetID]; ss != nil {
delete(ss, machineID)
}
t, ok := n.timers[machineID]
if ok {
t.Stop()
delete(n.timers, machineID)
}
timer := time.NewTimer(10 * time.Second)
go func() {
<-timer.C
if !n.HasSession(tailnetID, machineID) {
n.NotifyAll(tailnetID)
}
}()
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{}
}
}
}
+93 -61
View File
@@ -2,12 +2,13 @@ package database
import (
"context"
"encoding/json"
"errors"
"github.com/glebarez/sqlite"
"fmt"
"github.com/go-gormigrate/gormigrate/v2"
"github.com/hashicorp/go-hclog"
"net/http"
"tailscale.com/tailcfg"
"github.com/jsiebens/ionscale/internal/database/migration"
"github.com/jsiebens/ionscale/internal/util"
"tailscale.com/types/key"
"time"
"github.com/jsiebens/ionscale/internal/config"
@@ -16,76 +17,105 @@ import (
"gorm.io/gorm/logger"
)
func OpenDB(config *config.Database, logger hclog.Logger) (*gorm.DB, domain.Repository, error) {
gormDB, err := createDB(config, logger)
if err != nil {
return nil, nil, err
}
repository := domain.NewRepository(gormDB)
if err := migrate(gormDB, repository); err != nil {
return nil, nil, err
}
return gormDB, repository, nil
type db interface {
DB() *gorm.DB
Lock() error
Unlock() error
UnlockErr(error) error
}
func createDB(config *config.Database, logger hclog.Logger) (*gorm.DB, error) {
func OpenDB(config *config.Database, logger hclog.Logger) (domain.Repository, error) {
db, err := createDB(config, logger)
if err != nil {
return nil, err
}
repository := domain.NewRepository(db.DB())
if err := db.Lock(); err != nil {
return nil, err
}
if err := db.UnlockErr(migrate(db.DB())); err != nil {
return nil, err
}
return repository, nil
}
func createDB(config *config.Database, logger hclog.Logger) (db, error) {
gormConfig := &gorm.Config{
Logger: &GormLoggerAdapter{logger: logger.Named("db")},
}
return gorm.Open(sqlite.Open(config.Url), gormConfig)
switch config.Type {
case "sqlite", "sqlite3":
return newSqliteDB(config, gormConfig)
case "postgres", "postgresql":
return newPostgresDB(config, gormConfig)
}
return nil, fmt.Errorf("invalid database type '%s'", config.Type)
}
func migrate(db *gorm.DB, repository domain.Repository) error {
err := db.AutoMigrate(
&domain.ServerConfig{},
&domain.Tailnet{},
&domain.User{},
&domain.AuthKey{},
&domain.Machine{},
)
func migrate(db *gorm.DB) error {
m := gormigrate.New(db, gormigrate.DefaultOptions, migration.Migrations())
if err != nil {
if err := m.Migrate(); err != nil {
return err
}
if err := initializeDERPMap(repository); err != nil {
ctx := context.Background()
repository := domain.NewRepository(db)
if err := createServerKey(ctx, repository); err != nil {
return err
}
if err := createJSONWebKeySet(ctx, repository); err != nil {
return err
}
return nil
}
func initializeDERPMap(repository domain.Repository) error {
ctx := context.Background()
derpMap, err := repository.GetDERPMap(ctx)
func createServerKey(ctx context.Context, repository domain.Repository) error {
serverKey, err := repository.GetControlKeys(ctx)
if err != nil {
return err
}
if derpMap != nil {
if serverKey != nil {
return nil
}
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)
keys := domain.ControlKeys{
ControlKey: key.NewMachine(),
LegacyControlKey: key.NewMachine(),
}
m := &tailcfg.DERPMap{}
if err := getJson("https://controlplane.tailscale.com/derpmap/default", m); err != nil {
if err := repository.SetControlKeys(ctx, &keys); err != nil {
return err
}
if err := repository.SetDERPMap(ctx, m); err != nil {
return nil
}
func createJSONWebKeySet(ctx context.Context, repository domain.Repository) error {
jwks, err := repository.GetJSONWebKeySet(ctx)
if err != nil {
return err
}
if jwks != nil {
return nil
}
privateKey, id, err := util.NewPrivateKey()
if err != nil {
return err
}
jsonWebKey := domain.JSONWebKey{Id: id, PrivateKey: *privateKey}
if err := repository.SetJSONWebKeySet(ctx, &domain.JSONWebKeys{Key: jsonWebKey}); err != nil {
return err
}
@@ -113,21 +143,23 @@ 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) {
elapsed := time.Since(begin)
switch {
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound):
sql, rows := fc()
if rows == -1 {
g.logger.Error("Error executing query", "sql", sql, "start_time", begin.Format(time.RFC3339), "duration", elapsed, "err", err)
} else {
g.logger.Error("Error executing query", "sql", sql, "start_time", begin.Format(time.RFC3339), "duration", elapsed, "rows", rows, "err", err)
}
case g.logger.IsTrace():
sql, rows := fc()
if rows == -1 {
g.logger.Trace("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)
if g.logger.IsTrace() {
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)
} else {
g.logger.Trace("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)
} else {
g.logger.Trace("Statement executed", "sql", sql, "start_time", begin.Format(time.RFC3339), "duration", elapsed, "rows", rows)
}
}
}
}
@@ -0,0 +1,151 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"github.com/jsiebens/ionscale/internal/domain"
"gorm.io/gorm"
"time"
)
func m202209070900_initial_schema() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202209070900",
Migrate: func(db *gorm.DB) error {
// it's a good practice to copy the struct inside the function,
// so side effects are prevented if the original struct changes during the time
type ServerConfig struct {
Key string `gorm:"primaryKey"`
Value []byte
}
type Tailnet struct {
ID uint64 `gorm:"primaryKey;autoIncrement:false"`
Name string `gorm:"type:varchar(64);uniqueIndex"`
DNSConfig domain.DNSConfig
IAMPolicy domain.IAMPolicy
ACLPolicy domain.ACLPolicy
}
type Account struct {
ID uint64 `gorm:"primaryKey;autoIncrement:false"`
ExternalID string
LoginName string
}
type User struct {
ID uint64 `gorm:"primaryKey;autoIncrement:false"`
Name string
UserType domain.UserType
TailnetID uint64
Tailnet Tailnet
AccountID *uint64
Account *Account
}
type SystemApiKey struct {
ID uint64 `gorm:"primaryKey;autoIncrement:false"`
Key string `gorm:"type:varchar(64);uniqueIndex"`
Hash string
CreatedAt time.Time
ExpiresAt *time.Time
AccountID uint64
Account Account
}
type ApiKey struct {
ID uint64 `gorm:"primaryKey;autoIncrement:false"`
Key string `gorm:"type:varchar(64);uniqueIndex"`
Hash string
CreatedAt time.Time
ExpiresAt *time.Time
TailnetID uint64
Tailnet Tailnet
UserID uint64
User User
}
type AuthKey struct {
ID uint64 `gorm:"primaryKey;autoIncrement:false"`
Key string `gorm:"type:varchar(64);uniqueIndex"`
Hash string
Ephemeral bool
Tags domain.Tags
CreatedAt time.Time
ExpiresAt *time.Time
TailnetID uint64
Tailnet Tailnet
UserID uint64
User User
}
type Machine struct {
ID uint64 `gorm:"primaryKey;autoIncrement:false"`
Name string
NameIdx uint64
MachineKey string
NodeKey string
DiscoKey string
Ephemeral bool
RegisteredTags domain.Tags
Tags domain.Tags
KeyExpiryDisabled bool
HostInfo domain.HostInfo
Endpoints domain.Endpoints
AllowIPs domain.AllowIPs
IPv4 domain.IP
IPv6 domain.IP
CreatedAt time.Time
ExpiresAt time.Time
LastSeen *time.Time
UserID uint64
User User
TailnetID uint64
Tailnet Tailnet
}
type RegistrationRequest struct {
MachineKey string `gorm:"primaryKey;autoIncrement:false"`
Key string `gorm:"type:varchar(64);uniqueIndex"`
Data domain.RegistrationRequestData
CreatedAt time.Time
Authenticated bool
Error string
}
type AuthenticationRequest struct {
Key string `gorm:"primaryKey;autoIncrement:false"`
Token string
TailnetID *uint64
Error string
CreatedAt time.Time
}
return db.AutoMigrate(
&ServerConfig{},
&Tailnet{},
&Account{},
&User{},
&SystemApiKey{},
&ApiKey{},
&AuthKey{},
&Machine{},
&RegistrationRequest{},
&AuthenticationRequest{},
)
},
Rollback: nil,
}
}
@@ -0,0 +1,23 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"github.com/jsiebens/ionscale/internal/domain"
"gorm.io/gorm"
)
func m202209251530_add_autoallowips_column() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202209251530",
Migrate: func(db *gorm.DB) error {
type Machine struct {
AutoAllowIPs domain.AllowIPs
}
return db.AutoMigrate(
&Machine{},
)
},
Rollback: nil,
}
}
@@ -0,0 +1,39 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func m202209251532_add_alias_column() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202209251532a",
Migrate: func(db *gorm.DB) error {
type Tailnet struct {
Alias *string `gorm:"type:varchar(64)"`
}
return db.AutoMigrate(
&Tailnet{},
)
},
Rollback: nil,
}
}
func m202229251530_add_alias_column_constraint() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202209251532b",
Migrate: func(db *gorm.DB) error {
type Tailnet struct {
Name string `gorm:"uniqueIndex"`
Alias *string `gorm:"uniqueIndex"`
}
return db.AutoMigrate(
&Tailnet{},
)
},
Rollback: nil,
}
}
@@ -0,0 +1,25 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"github.com/jsiebens/ionscale/internal/domain"
"gorm.io/gorm"
)
func m202210040828_add_derpmap_colum() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202210040828",
Migrate: func(db *gorm.DB) error {
type Tailnet struct {
Name string `gorm:"uniqueIndex"`
Alias *string `gorm:"uniqueIndex"`
DERPMap domain.DERPMap
}
return db.AutoMigrate(
&Tailnet{},
)
},
Rollback: nil,
}
}
@@ -0,0 +1,25 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func m202210070814_add_filesharing_and_servicecollection_columns() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202210070814",
Migrate: func(db *gorm.DB) error {
type Tailnet struct {
Name string `gorm:"uniqueIndex"`
Alias *string `gorm:"uniqueIndex"`
ServiceCollectionEnabled bool
FileSharingEnabled bool
}
return db.AutoMigrate(
&Tailnet{},
)
},
Rollback: nil,
}
}
@@ -0,0 +1,34 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
"time"
)
func m202210080700_ssh_action_request() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202210080700",
Migrate: func(db *gorm.DB) error {
type Tailnet struct {
Name string `gorm:"uniqueIndex"`
Alias *string `gorm:"uniqueIndex"`
SSHEnabled bool
}
type SSHActionRequest struct {
Key string `gorm:"primary_key"`
Action string
SrcMachineID uint64
DstMachineID uint64
CreatedAt time.Time
}
return db.AutoMigrate(
&Tailnet{},
&SSHActionRequest{},
)
},
Rollback: nil,
}
}
@@ -0,0 +1,33 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func m202211031100_add_authorized_column() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202211031100",
Migrate: func(db *gorm.DB) error {
type Tailnet struct {
Name string `gorm:"uniqueIndex"`
MachineAuthorizationEnabled bool
}
type AuthKey struct {
PreAuthorized bool
}
type Machine struct {
Authorized bool `gorm:"default:true"`
}
return db.AutoMigrate(
&Tailnet{},
&AuthKey{},
&Machine{},
)
},
Rollback: nil,
}
}
@@ -0,0 +1,23 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func m202212201300_add_user_id_column() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202212201300",
Migrate: func(db *gorm.DB) error {
type RegistrationRequest struct {
Key string `gorm:"type:varchar(64);uniqueIndex"`
UserID uint64
}
return db.AutoMigrate(
&RegistrationRequest{},
)
},
Rollback: nil,
}
}
@@ -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,
}
}
+21
View File
@@ -0,0 +1,21 @@
package migration
import (
"github.com/go-gormigrate/gormigrate/v2"
)
func Migrations() []*gormigrate.Migration {
var migrations = []*gormigrate.Migration{
m202209070900_initial_schema(),
m202209251530_add_autoallowips_column(),
m202209251532_add_alias_column(),
m202229251530_add_alias_column_constraint(),
m202210040828_add_derpmap_colum(),
m202210070814_add_filesharing_and_servicecollection_columns(),
m202210080700_ssh_action_request(),
m202211031100_add_authorized_column(),
m202212201300_add_user_id_column(),
m202212270800_machine_indeces(),
}
return migrations
}
+69
View File
@@ -0,0 +1,69 @@
package database
import (
"context"
"fmt"
"hash/crc32"
"github.com/hashicorp/go-multierror"
"github.com/jsiebens/ionscale/internal/config"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func newPostgresDB(config *config.Database, g *gorm.Config) (db, error) {
db, err := gorm.Open(postgres.Open(config.Url), g)
if err != nil {
return nil, err
}
return &Postgres{
db: db,
}, nil
}
type Postgres struct {
db *gorm.DB
}
func (s *Postgres) DB() *gorm.DB {
return s.db
}
func (s *Postgres) Lock() error {
d, _ := s.db.DB()
query := `SELECT pg_advisory_lock($1)`
id := s.generateAdvisoryLockId()
if _, err := d.ExecContext(context.Background(), query, id); err != nil {
return err
}
return nil
}
func (s *Postgres) Unlock() error {
d, _ := s.db.DB()
query := `SELECT pg_advisory_unlock($1)`
if _, err := d.ExecContext(context.Background(), query, s.generateAdvisoryLockId()); err != nil {
return err
}
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 {
sum := crc32.ChecksumIEEE([]byte("ionscale_migration"))
sum = sum * uint32(advisoryLockIDSalt)
return fmt.Sprint(sum)
}
+38
View File
@@ -0,0 +1,38 @@
package database
import (
"github.com/glebarez/sqlite"
"github.com/jsiebens/ionscale/internal/config"
"gorm.io/gorm"
)
func newSqliteDB(config *config.Database, g *gorm.Config) (db, error) {
db, err := gorm.Open(sqlite.Open(config.Url), g)
if err != nil {
return nil, err
}
return &Sqlite{
db: db,
}, nil
}
type Sqlite struct {
db *gorm.DB
}
func (s *Sqlite) DB() *gorm.DB {
return s.db
}
func (s *Sqlite) Lock() error {
return nil
}
func (s *Sqlite) Unlock() error {
return nil
}
func (s *Sqlite) UnlockErr(prevErr error) error {
return prevErr
}
+164
View File
@@ -0,0 +1,164 @@
package dns
import (
"context"
"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"
"strings"
"time"
)
type Provider interface {
SetRecord(ctx context.Context, recordType, recordName, value string) error
}
func NewProvider(config config.DNS) (Provider, error) {
p := config.Provider
if len(p.Zone) == 0 {
return nil, nil
}
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)
}
}
func configureAzureProvider(zone string, values map[string]string) (Provider, error) {
p := &azure.Provider{}
if err := mapping.CopyViaJson(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
}
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 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 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 configureRoute53Provider(zone string, values map[string]string) (Provider, error) {
p := &route53.Provider{}
if err := mapping.CopyViaJson(values, p); err != nil {
return nil, err
}
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
}
type externalProvider struct {
zone string
setter libdns.RecordSetter
}
func (p *externalProvider) SetRecord(ctx context.Context, recordType, recordName, value string) error {
_, err := p.setter.SetRecords(ctx, fmt.Sprintf("%s.", p.zone), []libdns.Record{{
Type: recordType,
Name: strings.TrimSuffix(recordName, p.zone),
Value: value,
TTL: 1 * time.Minute,
}})
return err
}
+45
View File
@@ -0,0 +1,45 @@
package domain
import (
"context"
"errors"
"github.com/jsiebens/ionscale/internal/util"
"gorm.io/gorm"
)
type Account struct {
ID uint64 `gorm:"primary_key"`
ExternalID string
LoginName string
}
func (r *repository) GetOrCreateAccount(ctx context.Context, externalID, loginName string) (*Account, bool, error) {
account := &Account{}
id := util.NextID()
tx := r.withContext(ctx).
Where(Account{ExternalID: externalID}).
Attrs(Account{ID: id, LoginName: loginName}).
FirstOrCreate(account)
if tx.Error != nil {
return nil, false, tx.Error
}
return account, account.ID == id, nil
}
func (r *repository) GetAccount(ctx context.Context, id uint64) (*Account, error) {
var account Account
tx := r.withContext(ctx).Take(&account, "id = ?", id)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
if tx.Error != nil {
return nil, tx.Error
}
return &account, nil
}
+530
View File
@@ -0,0 +1,530 @@
package domain
import (
"database/sql/driver"
"encoding/json"
"fmt"
"github.com/hashicorp/go-multierror"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"net/netip"
"sort"
"strconv"
"strings"
"tailscale.com/tailcfg"
)
const (
AutoGroupSelf = "autogroup:self"
AutoGroupMembers = "autogroup:members"
AutoGroupInternet = "autogroup:internet"
)
type AutoApprovers struct {
Routes map[string][]string `json:"routes"`
ExitNode []string `json:"exitNode"`
}
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"`
}
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) FindAutoApprovedIPs(routableIPs []netip.Prefix, tags []string, u *User) []netip.Prefix {
if a.AutoApprovers == nil || len(routableIPs) == 0 {
return nil
}
matches := func(values []string) bool {
for _, alias := range values {
if alias == u.Name {
return true
}
group, ok := a.Groups[alias]
if ok {
for _, g := range group {
if g == u.Name {
return true
}
}
}
if strings.HasPrefix(alias, "tag:") {
for _, tag := range tags {
if alias == tag {
return true
}
}
}
}
return false
}
isAutoApproved := func(candidate netip.Prefix, approvedIPs []netip.Prefix) bool {
for _, approvedIP := range approvedIPs {
if candidate.Bits() >= approvedIP.Bits() && approvedIP.Contains(candidate.Masked().Addr()) {
return true
}
}
return false
}
autoApprovedIPs := []netip.Prefix{}
for route, autoApprovers := range a.AutoApprovers.Routes {
candidate, err := netip.ParsePrefix(route)
if err != nil {
return nil
}
if matches(autoApprovers) {
autoApprovedIPs = append(autoApprovedIPs, candidate)
}
}
result := []netip.Prefix{}
for _, c := range routableIPs {
if c.Bits() == 0 && matches(a.AutoApprovers.ExitNode) {
result = append(result, c)
}
if isAutoApproved(c, autoApprovedIPs) {
result = append(result, c)
}
}
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 {
if ok := a.isTagOwner(t, p); !ok {
result = multierror.Append(result, fmt.Errorf("tag [%s] is invalid or not permitted", t))
}
}
return result.ErrorOrNil()
}
func (a ACLPolicy) isTagOwner(tag string, p *User) bool {
if p.UserType == UserTypeService {
return true
}
if tagOwners, ok := a.TagOwners[tag]; ok {
return a.validateTagOwners(tagOwners, p)
}
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 {
return true
}
}
}
} else {
if alias == p.Name {
return true
}
}
}
return false
}
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) {
if s == "*" {
return []tailcfg.PortRange{{First: 0, Last: 65535}}, nil
}
ports := []tailcfg.PortRange{}
for _, p := range strings.Split(s, ",") {
rang := strings.Split(p, "-")
if len(rang) == 1 {
pi, err := strconv.ParseUint(rang[0], 10, 16)
if err != nil {
return nil, err
}
ports = append(ports, tailcfg.PortRange{
First: uint16(pi),
Last: uint16(pi),
})
} else if len(rang) == 2 {
start, err := strconv.ParseUint(rang[0], 10, 16)
if err != nil {
return nil, err
}
last, err := strconv.ParseUint(rang[1], 10, 16)
if err != nil {
return nil, err
}
ports = append(ports, tailcfg.PortRange{
First: uint16(start),
Last: uint16(last),
})
} else {
return nil, fmt.Errorf("invalid format")
}
}
return ports, nil
}
func (a ACLPolicy) isGroupMember(group string, m *Machine) bool {
if m.HasTags() {
return false
}
users, ok := a.Groups[group]
if !ok {
return false
}
for _, u := range users {
if m.HasUser(u) {
return true
}
}
return false
}
func (i *ACLPolicy) Scan(destination interface{}) error {
switch value := destination.(type) {
case []byte:
return json.Unmarshal(value, i)
default:
return fmt.Errorf("unexpected data type %T", destination)
}
}
func (i ACLPolicy) Value() (driver.Value, error) {
bytes, err := json.Marshal(i)
return bytes, err
}
// GormDataType gorm common data type
func (ACLPolicy) GormDataType() string {
return "json"
}
// GormDBDataType gorm db data type
func (ACLPolicy) GormDBDataType(db *gorm.DB, field *schema.Field) string {
switch db.Dialector.Name() {
case "sqlite":
return "JSON"
}
return ""
}
type StringSet struct {
items map[string]bool
}
func (s *StringSet) Add(t ...string) *StringSet {
if s.items == nil {
s.items = make(map[string]bool)
}
for _, v := range t {
s.items[v] = true
}
return s
}
func (s *StringSet) Items() []string {
items := []string{}
for i := range s.items {
items = append(items, i)
}
sort.Strings(items)
return items
}
func autogroupInternetRanges() []string {
return []string{
"0.0.0.0/5",
"8.0.0.0/7",
"11.0.0.0/8",
"12.0.0.0/6",
"16.0.0.0/4",
"32.0.0.0/3",
"64.0.0.0/3",
"96.0.0.0/6",
"100.0.0.0/10",
"100.128.0.0/9",
"101.0.0.0/8",
"102.0.0.0/7",
"104.0.0.0/5",
"112.0.0.0/4",
"128.0.0.0/3",
"160.0.0.0/5",
"168.0.0.0/8",
"169.0.0.0/9",
"169.128.0.0/10",
"169.192.0.0/11",
"169.224.0.0/12",
"169.240.0.0/13",
"169.248.0.0/14",
"169.252.0.0/15",
"169.255.0.0/16",
"170.0.0.0/7",
"172.0.0.0/12",
"172.32.0.0/11",
"172.64.0.0/10",
"172.128.0.0/9",
"173.0.0.0/8",
"174.0.0.0/7",
"176.0.0.0/4",
"192.0.0.0/9",
"192.128.0.0/11",
"192.160.0.0/13",
"192.169.0.0/16",
"192.170.0.0/15",
"192.172.0.0/14",
"192.176.0.0/12",
"192.192.0.0/10",
"193.0.0.0/8",
"194.0.0.0/7",
"196.0.0.0/6",
"200.0.0.0/5",
"208.0.0.0/4",
"224.0.0.0/3",
"2000::/3",
}
}
+153
View File
@@ -0,0 +1,153 @@
package domain
import (
"strings"
"tailscale.com/tailcfg"
)
func (a ACLPolicy) BuildSSHPolicy(srcs []Machine, dst *Machine) *tailcfg.SSHPolicy {
var rules []*tailcfg.SSHRule
expandSrcAliases := func(aliases []string, action string, u *User) []*tailcfg.SSHPrincipal {
var allSrcIPsSet = &StringSet{}
for _, alias := range aliases {
if strings.HasPrefix(alias, "tag:") && action == "check" {
continue
}
for _, src := range srcs {
srcIPs := a.expandSSHSrcAlias(&src, alias, u)
allSrcIPsSet.Add(srcIPs...)
}
}
var result = []*tailcfg.SSHPrincipal{}
for _, i := range allSrcIPsSet.Items() {
result = append(result, &tailcfg.SSHPrincipal{NodeIP: i})
}
return result
}
for _, rule := range a.SSHRules {
if rule.Action != "accept" && rule.Action != "check" {
continue
}
var action = &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
}
if rule.Action == "check" {
action = &tailcfg.SSHAction{
HoldAndDelegate: "https://unused/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID",
}
}
selfUsers, otherUsers := a.expandSSHDstToSSHUsers(dst, rule)
if len(selfUsers) != 0 {
principals := expandSrcAliases(rule.Src, rule.Action, &dst.User)
if len(principals) != 0 {
rules = append(rules, &tailcfg.SSHRule{
Principals: principals,
SSHUsers: selfUsers,
Action: action,
})
}
}
if len(otherUsers) != 0 {
principals := expandSrcAliases(rule.Src, rule.Action, nil)
if len(principals) != 0 {
rules = append(rules, &tailcfg.SSHRule{
Principals: principals,
SSHUsers: otherUsers,
Action: action,
})
}
}
}
return &tailcfg.SSHPolicy{Rules: rules}
}
func (a ACLPolicy) expandSSHSrcAlias(m *Machine, alias string, dstUser *User) []string {
if dstUser != nil {
if !m.HasUser(dstUser.Name) || m.HasTags() {
return []string{}
}
if alias == AutoGroupMembers {
return m.IPs()
}
if strings.Contains(alias, "@") && m.HasUser(alias) {
return m.IPs()
}
if strings.HasPrefix(alias, "group:") && a.isGroupMember(alias, m) {
return m.IPs()
}
return []string{}
}
if alias == AutoGroupMembers && !m.HasTags() {
return m.IPs()
}
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()
}
return []string{}
}
func (a ACLPolicy) expandSSHDstToSSHUsers(m *Machine, rule SSHRule) (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 {
if strings.HasPrefix(d, "tag:") && m.HasTag(d) {
otherUsers = users
}
if m.HasUser(d) || d == AutoGroupSelf {
selfUsers = users
}
}
return selfUsers, otherUsers
}
func buildSSHUsers(users []string) map[string]string {
var autogroupNonRoot = false
m := make(map[string]string)
for _, u := range users {
if u == "autogroup:nonroot" {
m["*"] = "="
autogroupNonRoot = true
} else {
m[u] = u
}
}
// disable root when autogroup:nonroot is used and root is not explicitly enabled
if _, exists := m["root"]; !exists && autogroupNonRoot {
m["root"] = ""
}
return m
}
+386
View File
@@ -0,0 +1,386 @@
package domain
import (
"encoding/json"
"fmt"
"github.com/stretchr/testify/assert"
"tailscale.com/tailcfg"
"testing"
)
func TestACLPolicy_BuildSSHPolicy_(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"autogroup:members"},
Dst: []string{"autogroup:self"},
Users: []string{"autogroup:nonroot"},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildSSHPolicy([]Machine{*p1, *p2}, dst)
expectedRules := []*tailcfg.SSHRule{
{
Principals: []*tailcfg.SSHPrincipal{
{NodeIP: p1.IPv4.String()},
{NodeIP: p1.IPv6.String()},
},
SSHUsers: map[string]string{
"*": "=",
"root": "",
},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
},
},
}
assert.Equal(t, expectedRules, actualRules.Rules)
}
func TestACLPolicy_BuildSSHPolicy_WithGroup(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("jane@example.com")
policy := 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"},
},
},
}
dst := createMachine("john@example.com", "tag:web")
actualRules := policy.BuildSSHPolicy([]Machine{*p1, *p2}, dst)
expectedRules := []*tailcfg.SSHRule{
{
Principals: []*tailcfg.SSHPrincipal{
{NodeIP: p1.IPv4.String()},
{NodeIP: p1.IPv6.String()},
},
SSHUsers: map[string]string{
"*": "=",
"root": "root",
},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
},
},
}
assert.Equal(t, expectedRules, actualRules.Rules)
}
func TestACLPolicy_BuildSSHPolicy_WithMatchingUsers(t *testing.T) {
p1 := createMachine("john@example.com")
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"},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildSSHPolicy([]Machine{*p1, *p2}, dst)
expectedRules := []*tailcfg.SSHRule{
{
Principals: sshPrincipalsFromMachines(*p1),
SSHUsers: map[string]string{
"*": "=",
"root": "root",
},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
},
},
}
assert.Equal(t, expectedRules, actualRules.Rules)
}
func TestACLPolicy_BuildSSHPolicy_WithMatchingUsersInGroup(t *testing.T) {
p1 := createMachine("john@example.com")
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"},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildSSHPolicy([]Machine{*p1, *p2}, dst)
expectedRules := []*tailcfg.SSHRule{
{
Principals: sshPrincipalsFromMachines(*p1),
SSHUsers: map[string]string{
"*": "=",
"root": "root",
},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
},
},
}
assert.Equal(t, expectedRules, actualRules.Rules)
}
func TestACLPolicy_BuildSSHPolicy_WithNoMatchingUsers(t *testing.T) {
p1 := createMachine("john@example.com")
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"},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildSSHPolicy([]Machine{*p1, *p2}, dst)
assert.Nil(t, actualRules.Rules)
}
func TestACLPolicy_BuildSSHPolicy_WithTags(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("nick@example.com")
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"},
},
},
}
dst := createMachine("john@example.com", "tag:web")
actualRules := policy.BuildSSHPolicy([]Machine{*p1, *p2, *p3}, dst)
expectedRules := []*tailcfg.SSHRule{
{
Principals: sshPrincipalsFromMachines(*p1, *p3),
SSHUsers: map[string]string{
"ubuntu": "ubuntu",
},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
},
},
}
assert.Equal(t, expectedRules, actualRules.Rules)
}
func TestACLPolicy_BuildSSHPolicy_WithTagsInDstAndAutogroupMemberInSrc(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("nick@example.com")
p3 := createMachine("nick@example.com", "tag:web")
policy := ACLPolicy{
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"autogroup:members"},
Dst: []string{"tag:web"},
Users: []string{"ubuntu"},
},
},
}
dst := createMachine("john@example.com", "tag:web")
actualRules := policy.BuildSSHPolicy([]Machine{*p1, *p2, *p3}, dst)
expectedRules := []*tailcfg.SSHRule{
{
Principals: sshPrincipalsFromMachines(*p1, *p2),
SSHUsers: map[string]string{
"ubuntu": "ubuntu",
},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
},
},
}
assert.Equal(t, expectedRules, actualRules.Rules)
}
func TestACLPolicy_BuildSSHPolicy_WithUserInDstAndNonMatchingSrc(t *testing.T) {
p1 := createMachine("john@example.com")
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"},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildSSHPolicy([]Machine{*p1, *p2}, dst)
assert.Nil(t, actualRules.Rules)
}
func TestACLPolicy_BuildSSHPolicy_WithUserInDstAndAutogroupMembersSrc(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
SSHRules: []SSHRule{
{
Action: "accept",
Src: []string{"autogroup:members"},
Dst: []string{"john@example.com"},
Users: []string{"autogroup:nonroot"},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildSSHPolicy([]Machine{*p1, *p2}, dst)
expectedRules := []*tailcfg.SSHRule{
{
Principals: sshPrincipalsFromMachines(*p1),
SSHUsers: map[string]string{
"*": "=",
"root": "",
},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
},
},
}
assert.Equal(t, expectedRules, actualRules.Rules)
}
func TestACLPolicy_BuildSSHPolicy_WithAutogroupSelfAndTagSrc(t *testing.T) {
p1 := createMachine("john@example.com")
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"},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildSSHPolicy([]Machine{*p1, *p2}, dst)
assert.Nil(t, actualRules.Rules)
}
func TestACLPolicy_BuildSSHPolicy_WithTagsAndActionCheck(t *testing.T) {
p1 := createMachine("john@example.com")
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"},
},
},
}
dst := createMachine("john@example.com", "tag:web")
actualRules := policy.BuildSSHPolicy([]Machine{*p1, *p2}, dst)
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 {
x.Add(m.IPv4.String(), m.IPv6.String())
}
var result = []*tailcfg.SSHPrincipal{}
for _, i := range x.Items() {
result = append(result, &tailcfg.SSHPrincipal{NodeIP: i})
}
return result
}
+653
View File
@@ -0,0 +1,653 @@
package domain
import (
"github.com/jsiebens/ionscale/internal/addr"
"github.com/stretchr/testify/assert"
"net/netip"
"sort"
"tailscale.com/tailcfg"
"testing"
)
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{"*:*"},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2}, dst)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: []string{"*"},
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
},
},
}
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"},
},
{
Action: "accept",
Src: []string{"group:audit"},
Dst: []string{"*:8000-8080"},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2, *p3}, dst)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: []string{
p1.IPv4.String(),
p1.IPv6.String(),
},
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
Ports: tailcfg.PortRange{
First: 22,
Last: 22,
},
},
},
},
{
SrcIPs: []string{
p2.IPv4.String(),
p2.IPv6.String(),
},
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
Ports: tailcfg.PortRange{
First: 8000,
Last: 8080,
},
},
},
},
}
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesWithAutoGroupMembers(t *testing.T) {
p1 := createMachine("jane@example.com")
p2 := createMachine("nick@example.com")
p3 := createMachine("joe@example.com", "tag:web")
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"autogroup:members"},
Dst: []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_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:*"},
},
},
}
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: dst.IPv4.String(),
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
{
IP: dst.IPv6.String(),
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
},
},
}
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesAutogroupSelfAndTags(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("john@example.com", "tag:web")
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"autogroup:self:*"},
},
},
}
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: dst.IPv4.String(),
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
{
IP: dst.IPv6.String(),
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
},
},
}
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesAutogroupSelfAndOtherDestinations(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("john@example.com", "tag:web")
p3 := createMachine("jane@example.com")
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"autogroup:self:22", "john@example.com:80"},
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2, *p3}, dst)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: p1.IPs(),
DstPorts: []tailcfg.NetPortRange{
{
IP: dst.IPv4.String(),
Ports: tailcfg.PortRange{
First: 22,
Last: 22,
},
},
{
IP: dst.IPv6.String(),
Ports: tailcfg.PortRange{
First: 22,
Last: 22,
},
},
},
},
{
SrcIPs: []string{"*"},
DstPorts: []tailcfg.NetPortRange{
{
IP: dst.IPv4.String(),
Ports: tailcfg.PortRange{
First: 80,
Last: 80,
},
},
{
IP: dst.IPv6.String(),
Ports: tailcfg.PortRange{
First: 80,
Last: 80,
},
},
},
},
}
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:*"},
},
},
}
dst := createMachine("john@example.com")
dst.AllowIPs = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
}
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{
p1.IPv4.String(),
p1.IPv6.String(),
},
DstPorts: expectedDstPorts,
},
}
assert.Equal(t, expectedRules, actualRules)
}
func TestWithUser(t *testing.T) {
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"john@example.com:*"},
},
},
}
src := createMachine("john@example.com")
assert.True(t, policy.IsValidPeer(src, createMachine("john@example.com")))
assert.False(t, policy.IsValidPeer(src, createMachine("john@example.com", "tag:web")))
assert.False(t, policy.IsValidPeer(src, createMachine("jane@example.com")))
}
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:*"},
},
},
}
src := createMachine("john@example.com")
assert.True(t, policy.IsValidPeer(src, createMachine("john@example.com")))
assert.False(t, policy.IsValidPeer(src, createMachine("jane@example.com")))
}
func TestWithTags(t *testing.T) {
policy := ACLPolicy{
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"tag:web:*"},
},
},
}
src := createMachine("john@example.com")
assert.True(t, policy.IsValidPeer(src, createMachine("john@example.com", "tag:web")))
assert.False(t, policy.IsValidPeer(src, createMachine("john@example.com", "tag:ci")))
}
func TestWithHosts(t *testing.T) {
dst1 := createMachine("john@example.com")
dst2 := createMachine("john@example.com")
policy := ACLPolicy{
Hosts: map[string]string{
"dst1": dst1.IPv4.String(),
},
ACLs: []ACL{
{
Action: "accept",
Src: []string{"*"},
Dst: []string{"dst1:*"},
},
},
}
src := createMachine("jane@example.com")
assert.True(t, policy.IsValidPeer(src, dst1))
assert.False(t, policy.IsValidPeer(src, dst2))
}
func createMachine(user string, tags ...string) *Machine {
ipv4, ipv6, err := addr.SelectIP(func(addr netip.Addr) (bool, error) {
return true, nil
})
if err != nil {
return nil
}
return &Machine{
IPv4: IP{ipv4},
IPv6: IP{ipv6},
User: User{
Name: user,
},
Tags: tags,
}
}
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"},
}}
testCases := []struct {
name string
tag string
userName string
userType UserType
expectErr bool
}{
{
name: "system admin is always a valid owner",
tag: "tag:web",
userName: "system admin",
userType: UserTypeService,
expectErr: false,
},
{
name: "system admin is always a valid owner",
tag: "tag:unknown",
userName: "system admin",
userType: UserTypeService,
expectErr: false,
},
{
name: "direct tag owner",
tag: "tag:web",
userName: "john@example.com",
userType: UserTypePerson,
expectErr: false,
},
{
name: "owner by group",
tag: "tag:web",
userName: "jane@example.com",
userType: UserTypePerson,
expectErr: false,
},
{
name: "unknown owner",
tag: "tag:web",
userName: "nick@example.com",
userType: UserTypePerson,
expectErr: true,
},
{
name: "unknown tag",
tag: "tag:unknown",
userName: "jane@example.com",
userType: UserTypePerson,
expectErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := policy.CheckTagOwners([]string{tc.tag}, &User{Name: tc.userName, UserType: tc.userType})
if tc.expectErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestACLPolicy_FindAutoApprovedIPsWhenNoAutoapproversAreSet(t *testing.T) {
route1 := netip.MustParsePrefix("10.160.0.0/20")
route2 := netip.MustParsePrefix("10.161.0.0/20")
route3 := netip.MustParsePrefix("10.162.0.0/20")
policy := ACLPolicy{}
assert.Nil(t, policy.FindAutoApprovedIPs([]netip.Prefix{route1, route2, route3}, nil, nil))
}
func TestACLPolicy_FindAutoApprovedIPs(t *testing.T) {
route1 := netip.MustParsePrefix("10.160.0.0/20")
route2 := netip.MustParsePrefix("10.161.0.0/20")
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"},
},
ExitNode: []string{"nick@example.com"},
},
}
testCases := []struct {
name string
tag []string
userName string
routableIPs []netip.Prefix
expected []netip.Prefix
}{
{
name: "nil",
tag: []string{},
userName: "john@example.com",
routableIPs: nil,
expected: nil,
},
{
name: "empty",
tag: []string{},
userName: "john@example.com",
routableIPs: []netip.Prefix{},
expected: nil,
},
{
name: "by user",
tag: []string{},
userName: "john@example.com",
routableIPs: []netip.Prefix{route1, route2, route3},
expected: []netip.Prefix{route2},
},
{
name: "partial by user",
tag: []string{},
userName: "john@example.com",
routableIPs: []netip.Prefix{netip.MustParsePrefix("10.161.4.0/22")},
expected: []netip.Prefix{netip.MustParsePrefix("10.161.4.0/22")},
},
{
name: "by tag",
tag: []string{"tag:router"},
routableIPs: []netip.Prefix{route1, route2, route3},
expected: []netip.Prefix{route2},
},
{
name: "by group",
userName: "jane@example.com",
routableIPs: []netip.Prefix{route1, route2, route3},
expected: []netip.Prefix{route1},
},
{
name: "no match",
userName: "nick@example.com",
routableIPs: []netip.Prefix{route1, route2, route3},
expected: []netip.Prefix{},
},
{
name: "exit",
userName: "nick@example.com",
routableIPs: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
expected: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
},
{
name: "exit no match",
userName: "john@example.com",
routableIPs: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
expected: []netip.Prefix{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actualAllowedIPs := policy.FindAutoApprovedIPs(tc.routableIPs, tc.tag, &User{Name: tc.userName})
assert.Equal(t, tc.expected, actualAllowedIPs)
})
}
}
+103
View File
@@ -0,0 +1,103 @@
package domain
import (
"context"
"errors"
"fmt"
"github.com/jsiebens/ionscale/internal/util"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"strings"
"time"
)
func CreateApiKey(tailnet *Tailnet, user *User, expiresAt *time.Time) (string, *ApiKey) {
key := util.RandStringBytes(12)
pwd := util.RandStringBytes(22)
value := fmt.Sprintf("%s_%s", key, pwd)
hash, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
return value, &ApiKey{
ID: util.NextID(),
Key: key,
Hash: string(hash),
CreatedAt: time.Now().UTC(),
ExpiresAt: expiresAt,
TailnetID: tailnet.ID,
UserID: user.ID,
}
}
type ApiKey struct {
ID uint64 `gorm:"primary_key"`
Key string
Hash string
CreatedAt time.Time
ExpiresAt *time.Time
TailnetID uint64
Tailnet Tailnet
UserID uint64
User User
}
func (r *repository) SaveApiKey(ctx context.Context, key *ApiKey) error {
tx := r.withContext(ctx).Save(key)
if tx.Error != nil {
return tx.Error
}
return nil
}
func (r *repository) LoadApiKey(ctx context.Context, key string) (*ApiKey, error) {
split := strings.Split(key, "_")
if len(split) != 2 {
return nil, nil
}
var m ApiKey
tx := r.withContext(ctx).Preload("User").Preload("Tailnet").Take(&m, "key = ?", split[0])
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
if tx.Error != nil {
return nil, tx.Error
}
if err := bcrypt.CompareHashAndPassword([]byte(m.Hash), []byte(split[1])); err != nil {
return nil, nil
}
if m.ExpiresAt != nil && !m.ExpiresAt.IsZero() && m.ExpiresAt.Before(time.Now()) {
return nil, nil
}
return &m, nil
}
func (r *repository) DeleteApiKeysByTailnet(ctx context.Context, tailnetID uint64) error {
tx := r.withContext(ctx).
Where("tailnet_id = ?", tailnetID).
Delete(&ApiKey{TailnetID: tailnetID})
return tx.Error
}
func (r *repository) DeleteApiKeysByUser(ctx context.Context, userID uint64) error {
tx := r.withContext(ctx).
Where("user_id = ?", userID).
Delete(&ApiKey{UserID: userID})
return tx.Error
}
+64 -15
View File
@@ -11,7 +11,7 @@ import (
"time"
)
func CreateAuthKey(tailnet *Tailnet, user *User, ephemeral bool, tags Tags, expiresAt *time.Time) (string, *AuthKey) {
func CreateAuthKey(tailnet *Tailnet, user *User, ephemeral bool, preAuthorized bool, tags Tags, expiresAt *time.Time) (string, *AuthKey) {
key := util.RandStringBytes(12)
pwd := util.RandStringBytes(22)
value := fmt.Sprintf("%s_%s", key, pwd)
@@ -22,13 +22,14 @@ func CreateAuthKey(tailnet *Tailnet, user *User, ephemeral bool, tags Tags, expi
}
return value, &AuthKey{
ID: util.NextID(),
Key: key,
Hash: string(hash),
Ephemeral: ephemeral,
Tags: tags,
CreatedAt: time.Now().UTC(),
ExpiresAt: expiresAt,
ID: util.NextID(),
Key: key,
Hash: string(hash),
Ephemeral: ephemeral,
PreAuthorized: preAuthorized,
Tags: tags,
CreatedAt: time.Now().UTC(),
ExpiresAt: expiresAt,
TailnetID: tailnet.ID,
UserID: user.ID,
@@ -36,11 +37,12 @@ func CreateAuthKey(tailnet *Tailnet, user *User, ephemeral bool, tags Tags, expi
}
type AuthKey struct {
ID uint64 `gorm:"primary_key;autoIncrement:false"`
Key string `gorm:"type:varchar(64);unique_index"`
Hash string
Ephemeral bool
Tags Tags
ID uint64 `gorm:"primary_key"`
Key string
Hash string
Ephemeral bool
PreAuthorized bool
Tags Tags
CreatedAt time.Time
ExpiresAt *time.Time
@@ -52,6 +54,24 @@ type AuthKey struct {
User User
}
func (r *repository) GetAuthKey(ctx context.Context, authKeyId uint64) (*AuthKey, error) {
var t AuthKey
tx := r.withContext(ctx).
Preload("User").
Preload("Tailnet").
Take(&t, "id = ?", authKeyId)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
if tx.Error != nil {
return nil, tx.Error
}
return &t, nil
}
func (r *repository) SaveAuthKey(ctx context.Context, key *AuthKey) error {
tx := r.withContext(ctx).Save(key)
@@ -67,6 +87,22 @@ func (r *repository) DeleteAuthKey(ctx context.Context, id uint64) (bool, error)
return tx.RowsAffected == 1, tx.Error
}
func (r *repository) DeleteAuthKeysByTailnet(ctx context.Context, tailnetID uint64) error {
tx := r.withContext(ctx).
Where("tailnet_id = ?", tailnetID).
Delete(&AuthKey{TailnetID: tailnetID})
return tx.Error
}
func (r *repository) DeleteAuthKeysByUser(ctx context.Context, userID uint64) error {
tx := r.withContext(ctx).
Where("user_id = ?", userID).
Delete(&AuthKey{UserID: userID})
return tx.Error
}
func (r *repository) ListAuthKeys(ctx context.Context, tailnetID uint64) ([]AuthKey, error) {
var authKeys = []AuthKey{}
tx := (r.withContext(ctx).
@@ -80,6 +116,19 @@ func (r *repository) ListAuthKeys(ctx context.Context, tailnetID uint64) ([]Auth
return authKeys, nil
}
func (r *repository) ListAuthKeysByTailnetAndUser(ctx context.Context, tailnetID, userID uint64) ([]AuthKey, error) {
var authKeys = []AuthKey{}
tx := (r.withContext(ctx).
Preload("User").
Preload("Tailnet")).
Where("tailnet_id = ? and user_id = ?", tailnetID, userID).
Find(&authKeys)
if tx.Error != nil {
return nil, tx.Error
}
return authKeys, nil
}
func (r *repository) LoadAuthKey(ctx context.Context, key string) (*AuthKey, error) {
split := strings.Split(key, "_")
if len(split) != 2 {
@@ -87,7 +136,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
@@ -101,7 +150,7 @@ func (r *repository) LoadAuthKey(ctx context.Context, key string) (*AuthKey, err
return nil, nil
}
if !m.ExpiresAt.IsZero() && m.ExpiresAt.Before(time.Now()) {
if m.ExpiresAt != nil && !m.ExpiresAt.IsZero() && m.ExpiresAt.Before(time.Now()) {
return nil, nil
}
+46
View File
@@ -0,0 +1,46 @@
package domain
import (
"context"
"errors"
"gorm.io/gorm"
"time"
)
type AuthenticationRequest struct {
Key string `gorm:"primary_key"`
Token string
TailnetID *uint64
Error string
CreatedAt time.Time
}
func (r *repository) SaveAuthenticationRequest(ctx context.Context, session *AuthenticationRequest) error {
tx := r.withContext(ctx).Save(session)
if tx.Error != nil {
return tx.Error
}
return nil
}
func (r *repository) GetAuthenticationRequest(ctx context.Context, key string) (*AuthenticationRequest, error) {
var m AuthenticationRequest
tx := r.withContext(ctx).Take(&m, "key = ?", key)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
if tx.Error != nil {
return nil, tx.Error
}
return &m, nil
}
func (r *repository) DeleteAuthenticationRequest(ctx context.Context, key string) error {
tx := r.withContext(ctx).Delete(&AuthenticationRequest{Key: key})
return tx.Error
}
+48
View File
@@ -0,0 +1,48 @@
package domain
import (
"context"
"database/sql/driver"
"encoding/json"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"tailscale.com/tailcfg"
)
type DERPMap struct {
Checksum string
DERPMap tailcfg.DERPMap
}
func (hi *DERPMap) Scan(destination interface{}) error {
switch value := destination.(type) {
case []byte:
return json.Unmarshal(value, hi)
default:
return fmt.Errorf("unexpected data type %T", destination)
}
}
func (hi DERPMap) Value() (driver.Value, error) {
bytes, err := json.Marshal(hi)
return bytes, err
}
// GormDataType gorm common data type
func (DERPMap) GormDataType() string {
return "json"
}
// GormDBDataType gorm db data type
func (DERPMap) GormDBDataType(db *gorm.DB, field *schema.Field) string {
switch db.Dialector.Name() {
case "sqlite":
return "JSON"
}
return ""
}
type DefaultDERPMap interface {
GetDERPMap(ctx context.Context) (*DERPMap, error)
}
+45
View File
@@ -0,0 +1,45 @@
package domain
import (
"database/sql/driver"
"encoding/json"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
type DNSConfig struct {
HttpsCertsEnabled bool `json:"http_certs"`
MagicDNS bool `json:"magic_dns"`
OverrideLocalDNS bool `json:"override_local_dns"`
Nameservers []string `json:"nameservers"`
Routes map[string][]string `json:"routes"`
}
func (i *DNSConfig) Scan(destination interface{}) error {
switch value := destination.(type) {
case []byte:
return json.Unmarshal(value, i)
default:
return fmt.Errorf("unexpected data type %T", destination)
}
}
func (i DNSConfig) Value() (driver.Value, error) {
bytes, err := json.Marshal(i)
return bytes, err
}
// GormDataType gorm common data type
func (DNSConfig) GormDataType() string {
return "json"
}
// GormDBDataType gorm db data type
func (DNSConfig) GormDBDataType(db *gorm.DB, field *schema.Field) string {
switch db.Dialector.Name() {
case "sqlite":
return "JSON"
}
return ""
}
+97
View File
@@ -0,0 +1,97 @@
package domain
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"github.com/hashicorp/go-bexpr"
"github.com/mitchellh/pointerstructure"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
type Identity struct {
UserID string
Username string
Email string
Attr map[string]interface{}
}
type IAMPolicy struct {
Subs []string `json:"subs,omitempty"`
Emails []string `json:"emails,omitempty"`
Filters []string `json:"filters,omitempty"`
Roles map[string]UserRole `json:"roles,omitempty"`
}
func (i *IAMPolicy) GetRole(user User) UserRole {
if val, ok := i.Roles[user.Name]; ok {
return val
}
return UserRoleMember
}
func (i *IAMPolicy) EvaluatePolicy(identity *Identity) (bool, error) {
for _, sub := range i.Subs {
if identity.UserID == sub {
return true, nil
}
}
for _, email := range i.Emails {
if identity.Email == email {
return true, nil
}
}
for _, f := range i.Filters {
if f == "*" {
return true, nil
}
evaluator, err := bexpr.CreateEvaluator(f)
if err != nil {
return false, err
}
result, err := evaluator.Evaluate(identity.Attr)
if err != nil && !errors.Is(err, pointerstructure.ErrNotFound) {
return false, err
}
if result {
return true, nil
}
}
return false, nil
}
func (i *IAMPolicy) Scan(destination interface{}) error {
switch value := destination.(type) {
case []byte:
return json.Unmarshal(value, i)
default:
return fmt.Errorf("unexpected data type %T", destination)
}
}
func (i IAMPolicy) Value() (driver.Value, error) {
bytes, err := json.Marshal(i)
return bytes, err
}
// GormDataType gorm common data type
func (IAMPolicy) GormDataType() string {
return "json"
}
// GormDBDataType gorm db data type
func (IAMPolicy) GormDBDataType(db *gorm.DB, field *schema.Field) string {
switch db.Dialector.Name() {
case "sqlite":
return "JSON"
}
return ""
}
+282 -19
View File
@@ -8,29 +8,34 @@ import (
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"net/netip"
"tailscale.com/tailcfg"
"time"
)
type Machine struct {
ID uint64 `gorm:"primary_key;autoIncrement:false"`
Name string
NameIdx uint64
MachineKey string
NodeKey string
DiscoKey string
Ephemeral bool
RegisteredTags Tags
Tags Tags
ID uint64 `gorm:"primary_key"`
Name string
NameIdx uint64
MachineKey string
NodeKey string
DiscoKey string
Ephemeral bool
RegisteredTags Tags
Tags Tags
KeyExpiryDisabled bool
Authorized bool
HostInfo HostInfo
Endpoints Endpoints
HostInfo HostInfo
Endpoints Endpoints
AllowIPs AllowIPs
AutoAllowIPs AllowIPs
IPv4 string
IPv6 string
IPv4 IP
IPv6 IP
CreatedAt time.Time
ExpiresAt *time.Time
ExpiresAt time.Time
LastSeen *time.Time
UserID uint64
@@ -42,6 +47,239 @@ type Machine struct {
type Machines []Machine
func (m *Machine) CompleteName() string {
if m.NameIdx != 0 {
return fmt.Sprintf("%s-%d", m.Name, m.NameIdx)
}
return m.Name
}
func (m *Machine) IPs() []string {
return []string{m.IPv4.String(), m.IPv6.String()}
}
func (m *Machine) IsExpired() bool {
return !m.KeyExpiryDisabled && !m.ExpiresAt.IsZero() && m.ExpiresAt.Before(time.Now())
}
func (m *Machine) HasIP(v netip.Addr) bool {
return v.Compare(*m.IPv4.Addr) == 0 || v.Compare(*m.IPv6.Addr) == 0
}
func (m *Machine) HasTag(tag string) bool {
for _, t := range m.Tags {
if t == tag {
return true
}
}
return false
}
func (m *Machine) HasUser(loginName string) bool {
return m.User.Name == loginName
}
func (m *Machine) HasTags() bool {
return len(m.Tags) != 0
}
func (m *Machine) IsAdvertisedExitNode() bool {
for _, r := range m.HostInfo.RoutableIPs {
if r.Bits() == 0 {
return true
}
}
return false
}
func (m *Machine) IsAllowedExitNode() bool {
for _, r := range m.AllowIPs {
if r.Bits() == 0 {
return true
}
}
for _, r := range m.AutoAllowIPs {
if r.Bits() == 0 {
return true
}
}
return false
}
func (m *Machine) AdvertisedPrefixes() []string {
result := []string{}
for _, r := range m.HostInfo.RoutableIPs {
if r.Bits() != 0 {
result = append(result, r.String())
}
}
return result
}
func (m *Machine) AllowedPrefixes() []string {
result := StringSet{}
for _, r := range m.AllowIPs {
if r.Bits() != 0 {
result.Add(r.String())
}
}
for _, r := range m.AutoAllowIPs {
if r.Bits() != 0 {
result.Add(r.String())
}
}
return result.Items()
}
func (m *Machine) IsAllowedIP(i netip.Addr) bool {
if m.HasIP(i) {
return true
}
for _, t := range m.AllowIPs {
if t.Contains(i) {
return true
}
}
for _, t := range m.AutoAllowIPs {
if t.Contains(i) {
return true
}
}
return false
}
func (m *Machine) IsAllowedIPPrefix(i netip.Prefix) bool {
for _, t := range m.AllowIPs {
if t.Overlaps(i) {
return true
}
}
for _, t := range m.AutoAllowIPs {
if t.Overlaps(i) {
return true
}
}
return false
}
func (m *Machine) IsExitNode() bool {
for _, t := range m.AllowIPs {
if t.Bits() == 0 {
return true
}
}
for _, t := range m.AutoAllowIPs {
if t.Bits() == 0 {
return true
}
}
return false
}
type IP struct {
*netip.Addr
}
func (i *IP) Scan(destination interface{}) error {
switch value := destination.(type) {
case string:
ip, err := netip.ParseAddr(value)
if err != nil {
return err
}
*i = IP{&ip}
return nil
default:
return fmt.Errorf("unexpected data type %T", destination)
}
}
func (i IP) Value() (driver.Value, error) {
if i.Addr == nil {
return nil, nil
}
return i.String(), nil
}
func (IP) GormDBDataType(db *gorm.DB, field *schema.Field) string {
switch db.Dialector.Name() {
case "postgres":
return "TEXT"
}
return ""
}
type AllowIPs []netip.Prefix
type AllowIPsSet struct {
items map[netip.Prefix]bool
}
func NewAllowIPsSet(t AllowIPs) *AllowIPsSet {
s := &AllowIPsSet{}
return s.Add(t...)
}
func (s *AllowIPsSet) Add(t ...netip.Prefix) *AllowIPsSet {
if s.items == nil {
s.items = make(map[netip.Prefix]bool)
}
for _, v := range t {
s.items[v] = true
}
return s
}
func (s *AllowIPsSet) Remove(t ...netip.Prefix) *AllowIPsSet {
if s.items == nil {
return s
}
for _, v := range t {
delete(s.items, v)
}
return s
}
func (s *AllowIPsSet) Items() []netip.Prefix {
items := []netip.Prefix{}
for i := range s.items {
items = append(items, i)
}
return items
}
func (hi *AllowIPs) Scan(destination interface{}) error {
switch value := destination.(type) {
case []byte:
return json.Unmarshal(value, hi)
default:
return fmt.Errorf("unexpected data type %T", destination)
}
}
func (hi AllowIPs) Value() (driver.Value, error) {
bytes, err := json.Marshal(hi)
return bytes, err
}
// GormDataType gorm common data type
func (AllowIPs) GormDataType() string {
return "json"
}
// GormDBDataType gorm db data type
func (AllowIPs) GormDBDataType(db *gorm.DB, field *schema.Field) string {
switch db.Dialector.Name() {
case "sqlite":
return "JSON"
}
return ""
}
type HostInfo tailcfg.Hostinfo
func (hi *HostInfo) Scan(destination interface{}) error {
@@ -119,7 +357,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").Take(&m, machineID)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
@@ -138,7 +376,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
@@ -153,7 +391,7 @@ func (r *repository) GetNextMachineNameIndex(ctx context.Context, tailnetID uint
func (r *repository) GetMachineByKey(ctx context.Context, tailnetID uint64, machineKey string) (*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, "tailnet_id = ? AND machine_key = ?", tailnetID, machineKey)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
@@ -168,7 +406,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
@@ -193,6 +431,28 @@ func (r *repository) CountMachinesWithIPv4(ctx context.Context, ip string) (int6
return count, nil
}
func (r *repository) CountMachineByTailnet(ctx context.Context, tailnetID uint64) (int64, error) {
var count int64
tx := r.withContext(ctx).Model(&Machine{}).Where("tailnet_id = ?", tailnetID).Count(&count)
if tx.Error != nil {
return 0, tx.Error
}
return count, nil
}
func (r *repository) DeleteMachineByTailnet(ctx context.Context, tailnetID uint64) error {
tx := r.withContext(ctx).Model(&Machine{}).Where("tailnet_id = ?", tailnetID).Delete(&Machine{})
return tx.Error
}
func (r *repository) DeleteMachineByUser(ctx context.Context, userID uint64) error {
tx := r.withContext(ctx).Model(&Machine{}).Where("user_id = ?", userID).Delete(&Machine{})
return tx.Error
}
func (r *repository) ListMachineByTailnet(ctx context.Context, tailnetID uint64) (Machines, error) {
var machines = []Machine{}
@@ -243,7 +503,10 @@ func (r *repository) ListInactiveEphemeralMachines(ctx context.Context, t time.T
func (r *repository) SetMachineLastSeen(ctx context.Context, machineID uint64) error {
now := time.Now().UTC()
tx := r.withContext(ctx).Model(Machine{}).Where("id = ?", machineID).Updates(map[string]interface{}{"last_seen": &now})
tx := r.withContext(ctx).
Model(Machine{}).
Where("id = ?", machineID).
Updates(map[string]interface{}{"last_seen": &now})
if tx.Error != nil {
return tx.Error
+23
View File
@@ -0,0 +1,23 @@
package domain
type Principal struct {
SystemRole SystemRole
User *User
UserRole UserRole
}
func (p Principal) IsSystemAdmin() bool {
return p.SystemRole.IsAdmin()
}
func (p Principal) IsTailnetAdmin(tailnetID uint64) bool {
return p.User.TailnetID == tailnetID && p.UserRole.IsAdmin()
}
func (p Principal) IsTailnetMember(tailnetID uint64) bool {
return p.User.TailnetID == tailnetID
}
func (p Principal) UserMatches(userID uint64) bool {
return p.User.ID == userID
}
+97
View File
@@ -0,0 +1,97 @@
package domain
import (
"context"
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"tailscale.com/tailcfg"
"time"
)
type RegistrationRequest struct {
MachineKey string `gorm:"primary_key"`
Key string
Data RegistrationRequestData
CreatedAt time.Time
Authenticated bool
Error string
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 {
switch value := destination.(type) {
case []byte:
return json.Unmarshal(value, hi)
default:
return fmt.Errorf("unexpected data type %T", destination)
}
}
func (hi RegistrationRequestData) Value() (driver.Value, error) {
bytes, err := json.Marshal(hi)
return bytes, err
}
// GormDataType gorm common data type
func (RegistrationRequestData) GormDataType() string {
return "json"
}
// GormDBDataType gorm db data type
func (RegistrationRequestData) GormDBDataType(db *gorm.DB, field *schema.Field) string {
switch db.Dialector.Name() {
case "sqlite":
return "JSON"
}
return ""
}
func (r *repository) SaveRegistrationRequest(ctx context.Context, request *RegistrationRequest) error {
tx := r.withContext(ctx).Save(request)
if tx.Error != nil {
return tx.Error
}
return nil
}
func (r *repository) GetRegistrationRequestByKey(ctx context.Context, key string) (*RegistrationRequest, error) {
var m RegistrationRequest
tx := r.withContext(ctx).Take(&m, "key = ?", key)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
if tx.Error != nil {
return nil, tx.Error
}
return &m, nil
}
func (r *repository) GetRegistrationRequestByMachineKey(ctx context.Context, key string) (*RegistrationRequest, error) {
var m RegistrationRequest
tx := r.withContext(ctx).Take(&m, "machine_key = ?", key)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
if tx.Error != nil {
return nil, tx.Error
}
return &m, nil
}
+103 -5
View File
@@ -2,26 +2,58 @@ package domain
import (
"context"
"encoding/json"
"github.com/jsiebens/ionscale/internal/util"
"gorm.io/gorm"
"net/http"
"sync"
"tailscale.com/tailcfg"
"time"
)
type Repository interface {
GetDERPMap(ctx context.Context) (*tailcfg.DERPMap, error)
SetDERPMap(ctx context.Context, v *tailcfg.DERPMap) error
GetControlKeys(ctx context.Context) (*ControlKeys, error)
SetControlKeys(ctx context.Context, keys *ControlKeys) error
GetOrCreateTailnet(ctx context.Context, name string) (*Tailnet, bool, 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)
@@ -31,21 +63,87 @@ type Repository interface {
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,
db: db,
defaultDERPMap: &derpMapCache{},
}
}
type repository struct {
db *gorm.DB
db *gorm.DB
defaultDERPMap *derpMapCache
}
func (r *repository) withContext(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx)
}
func (r *repository) Transaction(action func(Repository) error) error {
return r.db.Transaction(func(tx *gorm.DB) 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
}
+81 -8
View File
@@ -2,20 +2,50 @@ package domain
import (
"context"
"crypto"
"crypto/rsa"
"encoding/json"
"errors"
"gorm.io/gorm"
"tailscale.com/tailcfg"
tkey "tailscale.com/types/key"
"time"
)
type configKey string
const (
derpMapConfigKey configKey = "derp_map"
controlKeysConfigKey configKey = "control_keys"
jwksConfigKey configKey = "jwks"
)
type JSONWebKeys struct {
Key JSONWebKey
}
type JSONWebKey struct {
Id string
PrivateKey rsa.PrivateKey
CreatedAt time.Time
}
func (j JSONWebKey) Public() crypto.PublicKey {
return j.PrivateKey.Public()
}
type ServerConfig struct {
Key string `gorm:"primary_key"`
Key configKey `gorm:"primary_key"`
Value []byte
}
func (r *repository) GetDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
var m tailcfg.DERPMap
err := r.getServerConfig(ctx, "derp_map", &m)
type ControlKeys struct {
ControlKey tkey.MachinePrivate
LegacyControlKey tkey.MachinePrivate
}
func (r *repository) GetControlKeys(ctx context.Context) (*ControlKeys, error) {
var m ControlKeys
err := r.getServerConfig(ctx, controlKeysConfigKey, &m)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
@@ -28,11 +58,54 @@ func (r *repository) GetDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
return &m, nil
}
func (r *repository) SetDERPMap(ctx context.Context, v *tailcfg.DERPMap) error {
func (r *repository) SetControlKeys(ctx context.Context, v *ControlKeys) error {
return r.setServerConfig(ctx, controlKeysConfigKey, v)
}
func (r *repository) GetJSONWebKeySet(ctx context.Context) (*JSONWebKeys, error) {
var m JSONWebKeys
err := r.getServerConfig(ctx, jwksConfigKey, &m)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
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 string, v interface{}) error {
func (r *repository) getServerConfig(ctx context.Context, s configKey, v interface{}) error {
var m ServerConfig
tx := r.withContext(ctx).Take(&m, "key = ?", s)
@@ -48,7 +121,7 @@ func (r *repository) getServerConfig(ctx context.Context, s string, v interface{
return nil
}
func (r *repository) setServerConfig(ctx context.Context, s string, v interface{}) error {
func (r *repository) setServerConfig(ctx context.Context, s configKey, v interface{}) error {
marshal, err := json.Marshal(v)
if err != nil {
return err
+46
View File
@@ -0,0 +1,46 @@
package domain
import (
"context"
"errors"
"gorm.io/gorm"
"time"
)
type SSHActionRequest struct {
Key string `gorm:"primary_key"`
Action string
SrcMachineID uint64
DstMachineID uint64
CreatedAt time.Time
}
func (r *repository) SaveSSHActionRequest(ctx context.Context, session *SSHActionRequest) error {
tx := r.withContext(ctx).Save(session)
if tx.Error != nil {
return tx.Error
}
return nil
}
func (r *repository) GetSSHActionRequest(ctx context.Context, key string) (*SSHActionRequest, error) {
var m SSHActionRequest
tx := r.withContext(ctx).Take(&m, "key = ?", key)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
if tx.Error != nil {
return nil, tx.Error
}
return &m, nil
}
func (r *repository) DeleteSSHActionRequest(ctx context.Context, key string) error {
tx := r.withContext(ctx).Delete(&SSHActionRequest{Key: key})
return tx.Error
}
+91
View File
@@ -0,0 +1,91 @@
package domain
import (
"context"
"errors"
"fmt"
"github.com/jsiebens/ionscale/internal/util"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"strings"
"time"
)
func CreateSystemApiKey(account *Account, expiresAt *time.Time) (string, *SystemApiKey) {
key := util.RandStringBytes(12)
pwd := util.RandStringBytes(22)
value := fmt.Sprintf("sk_%s_%s", key, pwd)
hash, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
return value, &SystemApiKey{
ID: util.NextID(),
Key: key,
Hash: string(hash),
CreatedAt: time.Now().UTC(),
ExpiresAt: expiresAt,
AccountID: account.ID,
}
}
type SystemApiKey struct {
ID uint64 `gorm:"primary_key"`
Key string
Hash string
CreatedAt time.Time
ExpiresAt *time.Time
AccountID uint64
Account Account
}
func (r *repository) SaveSystemApiKey(ctx context.Context, key *SystemApiKey) error {
tx := r.withContext(ctx).Save(key)
if tx.Error != nil {
return tx.Error
}
return nil
}
func (r *repository) LoadSystemApiKey(ctx context.Context, token string) (*SystemApiKey, error) {
split := strings.Split(token, "_")
if len(split) != 3 {
return nil, nil
}
prefix := split[0]
key := split[1]
value := split[2]
if prefix != "sk" {
return nil, nil
}
var m SystemApiKey
tx := r.withContext(ctx).Preload("Account").Take(&m, "key = ?", key)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
if tx.Error != nil {
return nil, tx.Error
}
if err := bcrypt.CompareHashAndPassword([]byte(m.Hash), []byte(value)); err != nil {
return nil, nil
}
if !m.ExpiresAt.IsZero() && m.ExpiresAt.Before(time.Now()) {
return nil, nil
}
return &m, nil
}
+19 -14
View File
@@ -3,7 +3,9 @@ package domain
import (
"database/sql/driver"
"fmt"
"github.com/hashicorp/go-multierror"
"strings"
"tailscale.com/tailcfg"
)
type Tags []string
@@ -24,25 +26,28 @@ func (i *Tags) Scan(destination interface{}) error {
}
func (i Tags) Value() (driver.Value, error) {
if len(i) == 0 {
return "", nil
}
v := "|" + strings.Join(i, "|") + "|"
return v, nil
}
func SanitizeTags(input []string) Tags {
keys := make(map[string]bool)
var tags []string
for _, v := range input {
var entry string
if strings.HasPrefix(v, "tag:") {
entry = v[4:]
} else {
entry = v
}
func CheckTag(tag string) error {
return tailcfg.CheckTag(tag)
}
if _, value := keys[entry]; !value {
keys[entry] = true
tags = append(tags, entry)
func CheckTags(tags []string) error {
var result *multierror.Error
for _, t := range tags {
if err := CheckTag(t); err != nil {
result = multierror.Append(result, err)
}
}
return tags
return result.ErrorOrNil()
}
func SanitizeTags(input []string) Tags {
s := StringSet{}
return s.Add(input...).Items()
}
+73 -4
View File
@@ -5,18 +5,67 @@ import (
"errors"
"github.com/jsiebens/ionscale/internal/util"
"gorm.io/gorm"
"net/mail"
"strings"
"tailscale.com/util/dnsname"
)
type Tailnet struct {
ID uint64 `gorm:"primary_key;autoIncrement:false"`
Name string `gorm:"type:varchar(64);unique_index"`
ID uint64 `gorm:"primary_key"`
Name string
DNSConfig DNSConfig
IAMPolicy IAMPolicy
ACLPolicy ACLPolicy
DERPMap DERPMap
ServiceCollectionEnabled bool
FileSharingEnabled bool
SSHEnabled bool
MachineAuthorizationEnabled bool
}
func (r *repository) GetOrCreateTailnet(ctx context.Context, name string) (*Tailnet, bool, error) {
func (t Tailnet) GetDERPMap(ctx context.Context, fallack DefaultDERPMap) (*DERPMap, error) {
if t.DERPMap.Checksum == "" {
return fallack.GetDERPMap(ctx)
} else {
return &t.DERPMap, nil
}
}
func SanitizeTailnetName(name string) string {
name = strings.ToLower(name)
a, err := mail.ParseAddress(name)
if err == nil && a.Address == name {
s := strings.Split(name, "@")
return strings.Join([]string{dnsname.SanitizeLabel(s[0]), s[1]}, ".")
}
labels := strings.Split(name, ".")
for i, s := range labels {
labels[i] = dnsname.SanitizeLabel(s)
}
return strings.Join(labels, ".")
}
func (r *repository) SaveTailnet(ctx context.Context, tailnet *Tailnet) error {
tx := r.withContext(ctx).Save(tailnet)
if tx.Error != nil {
return tx.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}).FirstOrCreate(tailnet)
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
@@ -40,6 +89,21 @@ 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) {
var t Tailnet
tx := r.withContext(ctx).Take(&t, "alias = ?", alias)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
if tx.Error != nil {
return nil, tx.Error
}
return &t, nil
}
func (r *repository) ListTailnets(ctx context.Context) ([]Tailnet, error) {
var tailnets = []Tailnet{}
tx := r.withContext(ctx).Find(&tailnets)
@@ -48,3 +112,8 @@ func (r *repository) ListTailnets(ctx context.Context) ([]Tailnet, error) {
}
return tailnets, nil
}
func (r *repository) DeleteTailnet(ctx context.Context, id uint64) error {
tx := r.withContext(ctx).Delete(&Tailnet{ID: id})
return tx.Error
}
+15
View File
@@ -0,0 +1,15 @@
package domain
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestSanitizeTailnetName(t *testing.T) {
assert.Equal(t, "john.example.com", SanitizeTailnetName("john@example.com"))
assert.Equal(t, "john.example.com", SanitizeTailnetName("john@examPle.Com"))
assert.Equal(t, "john-doe.example.com", SanitizeTailnetName("john.doe@example.com"))
assert.Equal(t, "johns-network", SanitizeTailnetName("John's Network"))
assert.Equal(t, "example.com", SanitizeTailnetName("example.com"))
assert.Equal(t, "johns-example.com", SanitizeTailnetName("John's example.com"))
}
+79 -11
View File
@@ -2,22 +2,49 @@ package domain
import (
"context"
"errors"
"github.com/jsiebens/ionscale/internal/util"
"gorm.io/gorm"
)
type TailnetRole string
type SystemRole string
const (
TailnetRoleService TailnetRole = "service"
SystemRoleNone SystemRole = ""
SystemRoleAdmin SystemRole = "admin"
)
type User struct {
ID uint64 `gorm:"primary_key;autoIncrement:false"`
Name string
func (s SystemRole) IsAdmin() bool {
return s == SystemRoleAdmin
}
TailnetRole TailnetRole
TailnetID uint64
Tailnet Tailnet
type UserType string
const (
UserTypeService UserType = "service"
UserTypePerson UserType = "person"
)
type UserRole string
const (
UserRoleNone UserRole = ""
UserRoleMember UserRole = "member"
UserRoleAdmin UserRole = "admin"
)
func (s UserRole) IsAdmin() bool {
return s == UserRoleAdmin
}
type User struct {
ID uint64 `gorm:"primary_key"`
Name string
UserType UserType
TailnetID uint64
Tailnet Tailnet
AccountID *uint64
Account *Account
}
type Users []User
@@ -26,8 +53,8 @@ func (r *repository) GetOrCreateServiceUser(ctx context.Context, tailnet *Tailne
user := &User{}
id := util.NextID()
query := User{Name: tailnet.Name, TailnetID: tailnet.ID, TailnetRole: TailnetRoleService}
attrs := User{ID: id, Name: tailnet.Name, TailnetID: tailnet.ID, TailnetRole: TailnetRoleService}
query := User{Name: tailnet.Name, TailnetID: tailnet.ID, UserType: UserTypeService}
attrs := User{ID: id, Name: tailnet.Name, TailnetID: tailnet.ID, UserType: UserTypeService}
tx := r.withContext(ctx).Where(query).Attrs(attrs).FirstOrCreate(user)
@@ -41,7 +68,7 @@ func (r *repository) GetOrCreateServiceUser(ctx context.Context, tailnet *Tailne
func (r *repository) ListUsers(ctx context.Context, tailnetID uint64) (Users, error) {
var users = []User{}
tx := r.withContext(ctx).Where("tailnet_id = ?", tailnetID).Find(&users)
tx := r.withContext(ctx).Where("tailnet_id = ? AND user_type = ?", tailnetID, UserTypePerson).Find(&users)
if tx.Error != nil {
return nil, tx.Error
@@ -49,3 +76,44 @@ func (r *repository) ListUsers(ctx context.Context, tailnetID uint64) (Users, er
return users, nil
}
func (r *repository) DeleteUsersByTailnet(ctx context.Context, tailnetID uint64) error {
tx := r.withContext(ctx).Where("tailnet_id = ?", tailnetID).Delete(&User{})
return tx.Error
}
func (r *repository) GetOrCreateUserWithAccount(ctx context.Context, tailnet *Tailnet, account *Account) (*User, bool, error) {
user := &User{}
id := util.NextID()
query := User{AccountID: &account.ID, TailnetID: tailnet.ID}
attrs := User{ID: id, Name: account.LoginName, TailnetID: tailnet.ID, AccountID: &account.ID, UserType: UserTypePerson}
tx := r.withContext(ctx).Where(query).Attrs(attrs).FirstOrCreate(user)
if tx.Error != nil {
return nil, false, tx.Error
}
return user, user.ID == id, nil
}
func (r *repository) GetUser(ctx context.Context, userID uint64) (*User, error) {
var m User
tx := r.withContext(ctx).Preload("Tailnet").Preload("Account").Take(&m, "id = ?", userID)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
if tx.Error != nil {
return nil, tx.Error
}
return &m, nil
}
func (r *repository) DeleteUser(ctx context.Context, userID uint64) error {
tx := r.withContext(ctx).Delete(&User{ID: userID})
return tx.Error
}
+42
View File
@@ -0,0 +1,42 @@
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)
}
+472 -62
View File
@@ -1,51 +1,283 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"github.com/jsiebens/ionscale/internal/addr"
"github.com/jsiebens/ionscale/internal/auth"
"github.com/jsiebens/ionscale/internal/errors"
"github.com/labstack/echo/v4/middleware"
"github.com/mr-tron/base58"
"net/http"
"tailscale.com/tailcfg"
"time"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/util"
"github.com/labstack/echo/v4"
"github.com/patrickmn/go-cache"
"tailscale.com/util/dnsname"
)
func NewAuthenticationHandlers(
config *config.Config,
repository domain.Repository,
pendingMachineRegistrationRequests *cache.Cache) *AuthenticationHandlers {
authProvider auth.Provider,
systemIAMPolicy *domain.IAMPolicy,
repository domain.Repository) *AuthenticationHandlers {
return &AuthenticationHandlers{
config: config,
repository: repository,
pendingMachineRegistrationRequests: pendingMachineRegistrationRequests,
config: config,
authProvider: authProvider,
repository: repository,
systemIAMPolicy: systemIAMPolicy,
}
}
type AuthenticationHandlers struct {
repository domain.Repository
config *config.Config
pendingMachineRegistrationRequests *cache.Cache
repository domain.Repository
authProvider auth.Provider
config *config.Config
systemIAMPolicy *domain.IAMPolicy
}
type AuthFormData struct {
ProviderAvailable bool
Csrf string
}
type TailnetSelectionData struct {
AccountID uint64
Tailnets []domain.Tailnet
SystemAdmin bool
Csrf string
}
type oauthState struct {
Key string
Flow string
}
func (h *AuthenticationHandlers) StartAuth(c echo.Context) error {
ctx := c.Request().Context()
flow := c.Param("flow")
key := c.Param("key")
// 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)
}
csrf := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
return c.Render(http.StatusOK, "auth.html", &AuthFormData{ProviderAvailable: h.authProvider != nil, Csrf: csrf})
}
// cli auth flow
if flow == "c" {
if s, err := h.repository.GetAuthenticationRequest(ctx, key); err != nil || s == nil {
return errors.Wrap(err, 0)
}
}
// 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 h.authProvider == nil {
return errors.Wrap(fmt.Errorf("unable to start auth flow as no auth provider is configured"), 0)
}
state, err := h.createState(flow, key)
if err != nil {
return errors.Wrap(err, 0)
}
redirectUrl := h.authProvider.GetLoginURL(h.config.CreateUrl("/a/callback"), state)
return c.Redirect(http.StatusFound, redirectUrl)
}
func (h *AuthenticationHandlers) ProcessAuth(c echo.Context) error {
ctx := c.Request().Context()
key := c.Param("key")
authKey := c.FormValue("ak")
interactive := c.FormValue("s")
if _, ok := h.pendingMachineRegistrationRequests.Get(key); !ok {
return c.Redirect(http.StatusFound, "/a/error")
req, err := h.repository.GetRegistrationRequestByKey(ctx, key)
if err != nil || req == nil {
return errors.Wrap(err, 0)
}
if authKey != "" {
return h.endMachineRegistrationFlow(c, key, authKey)
return h.endMachineRegistrationFlow(c, req, &oauthState{Key: key})
}
return c.Render(http.StatusOK, "auth.html", nil)
if interactive != "" {
state, err := h.createState("r", key)
if err != nil {
return errors.Wrap(err, 0)
}
redirectUrl := h.authProvider.GetLoginURL(h.config.CreateUrl("/a/callback"), state)
return c.Redirect(http.StatusFound, redirectUrl)
}
return c.Redirect(http.StatusFound, "/a/"+key)
}
func (h *AuthenticationHandlers) Callback(c echo.Context) error {
ctx := c.Request().Context()
code := c.QueryParam("code")
state, err := h.readState(c.QueryParam("state"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid state parameter")
}
user, err := h.exchangeUser(code)
if err != nil {
return errors.Wrap(err, 0)
}
account, _, err := h.repository.GetOrCreateAccount(ctx, user.ID, user.Name)
if err != nil {
return errors.Wrap(err, 0)
}
if state.Flow == "s" {
sshActionReq, err := h.repository.GetSSHActionRequest(ctx, state.Key)
if err != nil || sshActionReq == nil {
return c.Redirect(http.StatusFound, "/a/error?e=ua")
}
machine, err := h.repository.GetMachine(ctx, sshActionReq.SrcMachineID)
if err != nil || sshActionReq == nil {
return errors.Wrap(err, 0)
}
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)
}
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 c.Redirect(http.StatusFound, "/a/error?e=nmo")
}
tailnets, err := h.listAvailableTailnets(ctx, user)
if err != nil {
return errors.Wrap(err, 0)
}
csrf := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
if state.Flow == "r" {
if len(tailnets) == 0 {
registrationRequest, err := h.repository.GetRegistrationRequestByKey(ctx, state.Key)
if err == nil && registrationRequest != nil {
registrationRequest.Error = "unauthorized"
_ = h.repository.SaveRegistrationRequest(ctx, registrationRequest)
}
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 state.Flow == "c" {
isSystemAdmin, err := h.isSystemAdmin(ctx, user)
if err != nil {
return errors.Wrap(err, 0)
}
if !isSystemAdmin && len(tailnets) == 0 {
req, err := h.repository.GetAuthenticationRequest(ctx, state.Key)
if err == nil && req != nil {
req.Error = "unauthorized"
_ = h.repository.SaveAuthenticationRequest(ctx, req)
}
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 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 {
ctx := c.Request().Context()
state, err := h.readState(c.QueryParam("state"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid state parameter")
}
if state.Flow == "r" {
req, err := h.repository.GetRegistrationRequestByKey(ctx, state.Key)
if err != nil || req == nil {
return errors.Wrap(err, 0)
}
return h.endMachineRegistrationFlow(c, req, state)
}
req, err := h.repository.GetAuthenticationRequest(ctx, state.Key)
if err != nil || req == nil {
return errors.Wrap(err, 0)
}
return h.endCliAuthenticationFlow(c, req, state)
}
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, "success.html", nil)
}
@@ -54,84 +286,208 @@ func (h *AuthenticationHandlers) Error(c echo.Context) error {
switch e {
case "iak":
return c.Render(http.StatusForbidden, "invalidauthkey.html", nil)
case "ua":
return c.Render(http.StatusForbidden, "unauthorized.html", nil)
case "nto":
return c.Render(http.StatusForbidden, "notagowner.html", nil)
case "nmo":
return c.Render(http.StatusForbidden, "notmachineowner.html", nil)
}
return c.Render(http.StatusOK, "error.html", nil)
}
func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, registrationKey, authKeyParam string) 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 {
ctx := c.Request().Context()
defer h.pendingMachineRegistrationRequests.Delete(registrationKey)
preqItem, preqOK := h.pendingMachineRegistrationRequests.Get(registrationKey)
if !preqOK {
return c.Redirect(http.StatusFound, "/a/error")
var form TailnetSelectionForm
if err := c.Bind(&form); err != nil {
return errors.Wrap(err, 0)
}
preq := preqItem.(*pendingMachineRegistrationRequest)
req := preq.request
machineKey := preq.machineKey
account, err := h.repository.GetAccount(ctx, form.AccountID)
if err != nil {
return errors.Wrap(err, 0)
}
// continue as system admin?
if form.AsSystemAdmin {
expiresAt := time.Now().Add(24 * time.Hour)
token, apiKey := domain.CreateSystemApiKey(account, &expiresAt)
req.Token = token
err := h.repository.Transaction(func(rp domain.Repository) error {
if err := rp.SaveSystemApiKey(ctx, apiKey); err != nil {
return errors.Wrap(err, 0)
}
if err := rp.SaveAuthenticationRequest(ctx, req); err != nil {
return errors.Wrap(err, 0)
}
return nil
})
if err != nil {
return errors.Wrap(err, 0)
}
return c.Redirect(http.StatusFound, "/a/success")
}
tailnet, err := h.repository.GetTailnet(ctx, form.TailnetID)
if err != nil {
return errors.Wrap(err, 0)
}
user, _, err := h.repository.GetOrCreateUserWithAccount(ctx, tailnet, account)
if err != nil {
return errors.Wrap(err, 0)
}
expiresAt := time.Now().Add(24 * time.Hour)
token, apiKey := domain.CreateApiKey(tailnet, user, &expiresAt)
req.Token = token
req.TailnetID = &tailnet.ID
err = h.repository.Transaction(func(rp domain.Repository) error {
if err := rp.SaveApiKey(ctx, apiKey); err != nil {
return err
}
if err := rp.SaveAuthenticationRequest(ctx, req); err != nil {
return err
}
return nil
})
if err != nil {
return errors.Wrap(err, 0)
}
return c.Redirect(http.StatusFound, "/a/success")
}
func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, registrationRequest *domain.RegistrationRequest, state *oauthState) 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()
authKey, err := h.repository.LoadAuthKey(ctx, authKeyParam)
if err != nil {
return err
var tailnet *domain.Tailnet
var user *domain.User
var ephemeral bool
var tags = []string{}
var authorized = false
if form.AuthKey != "" {
authKey, err := h.repository.LoadAuthKey(ctx, form.AuthKey)
if err != nil {
return errors.Wrap(err, 0)
}
if authKey == nil {
registrationRequest.Authenticated = false
registrationRequest.Error = "invalid auth key"
if err := h.repository.SaveRegistrationRequest(ctx, registrationRequest); err != nil {
return errors.Wrap(err, 0)
}
return c.Redirect(http.StatusFound, "/a/error?e=iak")
}
tailnet = &authKey.Tailnet
user = &authKey.User
tags = authKey.Tags
ephemeral = authKey.Ephemeral
authorized = authKey.PreAuthorized
} else {
selectedTailnet, err := h.repository.GetTailnet(ctx, form.TailnetID)
if err != nil {
return errors.Wrap(err, 0)
}
account, err := h.repository.GetAccount(ctx, form.AccountID)
if err != nil {
return errors.Wrap(err, 0)
}
selectedUser, _, err := h.repository.GetOrCreateUserWithAccount(ctx, selectedTailnet, account)
if err != nil {
return errors.Wrap(err, 0)
}
user = selectedUser
tailnet = selectedTailnet
ephemeral = false
}
if authKey == nil {
return c.Redirect(http.StatusFound, "/a/error?e=iak")
if err := tailnet.ACLPolicy.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 c.Redirect(http.StatusFound, "/a/error?e=nto")
}
tailnet := authKey.Tailnet
user := authKey.User
autoAllowIPs := tailnet.ACLPolicy.FindAutoApprovedIPs(req.Hostinfo.RoutableIPs, tags, user)
var m *domain.Machine
m, err = h.repository.GetMachineByKey(ctx, tailnet.ID, machineKey)
m, err := h.repository.GetMachineByKey(ctx, tailnet.ID, machineKey)
if err != nil {
return err
return errors.Wrap(err, 0)
}
if m == nil {
now := time.Now().UTC()
now := time.Now().UTC()
registeredTags := authKey.Tags
if m == nil {
registeredTags := tags
advertisedTags := domain.SanitizeTags(req.Hostinfo.RequestTags)
tags := append(registeredTags, advertisedTags...)
sanitizeHostname := dnsname.SanitizeHostname(req.Hostinfo.Hostname)
nameIdx, err := h.repository.GetNextMachineNameIndex(ctx, tailnet.ID, sanitizeHostname)
if err != nil {
return err
return errors.Wrap(err, 0)
}
m = &domain.Machine{
ID: util.NextID(),
Name: sanitizeHostname,
NameIdx: nameIdx,
MachineKey: machineKey,
NodeKey: nodeKey,
Ephemeral: authKey.Ephemeral,
RegisteredTags: registeredTags,
Tags: domain.SanitizeTags(tags),
CreatedAt: now,
ID: util.NextID(),
Name: sanitizeHostname,
NameIdx: nameIdx,
MachineKey: machineKey,
NodeKey: nodeKey,
Ephemeral: ephemeral || req.Ephemeral,
RegisteredTags: registeredTags,
Tags: domain.SanitizeTags(tags),
AutoAllowIPs: autoAllowIPs,
CreatedAt: now,
ExpiresAt: now.Add(180 * 24 * time.Hour).UTC(),
KeyExpiryDisabled: len(tags) != 0,
Authorized: !tailnet.MachineAuthorizationEnabled || authorized,
User: user,
Tailnet: tailnet,
}
if !req.Expiry.IsZero() {
m.ExpiresAt = &req.Expiry
User: *user,
Tailnet: *tailnet,
}
ipv4, ipv6, err := addr.SelectIP(checkIP(ctx, h.repository.CountMachinesWithIPv4))
if err != nil {
return err
return errors.Wrap(err, 0)
}
m.IPv4 = ipv4.String()
m.IPv6 = ipv6.String()
m.IPv4 = domain.IP{Addr: ipv4}
m.IPv6 = domain.IP{Addr: ipv6}
} else {
registeredTags := authKey.Tags
registeredTags := tags
advertisedTags := domain.SanitizeTags(req.Hostinfo.RequestTags)
tags := append(registeredTags, advertisedTags...)
@@ -139,25 +495,79 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
if m.Name != sanitizeHostname {
nameIdx, err := h.repository.GetNextMachineNameIndex(ctx, tailnet.ID, sanitizeHostname)
if err != nil {
return err
return errors.Wrap(err, 0)
}
m.Name = sanitizeHostname
m.NameIdx = nameIdx
}
m.NodeKey = nodeKey
m.Ephemeral = authKey.Ephemeral
m.Ephemeral = ephemeral || req.Ephemeral
m.RegisteredTags = registeredTags
m.Tags = domain.SanitizeTags(tags)
m.AutoAllowIPs = autoAllowIPs
m.UserID = user.ID
m.User = user
m.User = *user
m.TailnetID = tailnet.ID
m.Tailnet = tailnet
m.ExpiresAt = nil
m.Tailnet = *tailnet
m.ExpiresAt = now.Add(180 * 24 * time.Hour).UTC()
}
if err := h.repository.SaveMachine(ctx, m); err != nil {
return err
err = h.repository.Transaction(func(rp domain.Repository) error {
registrationRequest.Authenticated = true
registrationRequest.Error = ""
registrationRequest.UserID = user.ID
if err := rp.SaveMachine(ctx, m); err != nil {
return err
}
if err := rp.SaveRegistrationRequest(ctx, registrationRequest); err != nil {
return err
}
return nil
})
if err != nil {
return errors.Wrap(err, 0)
}
return c.Redirect(http.StatusFound, "/a/success")
if m.Authorized {
return c.Redirect(http.StatusFound, "/a/success")
} else {
return c.Redirect(http.StatusFound, "/a/success?s=nma")
}
}
func (h *AuthenticationHandlers) exchangeUser(code string) (*auth.User, error) {
redirectUrl := h.config.CreateUrl("/a/callback")
user, err := h.authProvider.Exchange(redirectUrl, code)
if err != nil {
return nil, err
}
return user, nil
}
func (h *AuthenticationHandlers) createState(flow string, key string) (string, error) {
stateMap := oauthState{Key: key, Flow: flow}
marshal, err := json.Marshal(&stateMap)
if err != nil {
return "", err
}
return base58.FastBase58Encoding(marshal), nil
}
func (h *AuthenticationHandlers) readState(s string) (*oauthState, error) {
decodedState, err := base58.FastBase58Decoding(s)
if err != nil {
return nil, err
}
var state = &oauthState{}
if err := json.Unmarshal(decodedState, state); err != nil {
return nil, err
}
return state, nil
}
+74
View File
@@ -0,0 +1,74 @@
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"
"time"
)
func NewDNSHandlers(createBinder bind.Factory, provider dns.Provider) *DNSHandlers {
return &DNSHandlers{
createBinder: createBinder,
provider: provider,
}
}
type DNSHandlers struct {
createBinder bind.Factory
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 := binder.BindRequest(c, req); err != nil {
return errors.Wrap(err, 0)
}
if h.provider == nil {
return echo.NewHTTPError(http.StatusNotFound)
}
if err := h.provider.SetRecord(ctx, req.Type, req.Name, req.Value); err != nil {
return errors.Wrap(err, 0)
}
if strings.HasPrefix(req.Name, "_acme-challenge") && req.Type == "TXT" {
// Listen to connection close
notify := ctx.Done()
timeout := time.After(5 * time.Minute)
tick := time.NewTicker(5 * time.Second)
defer func() { tick.Stop() }()
for {
select {
case <-tick.C:
txtrecords, _ := net.LookupTXT(req.Name)
for _, txt := range txtrecords {
if txt == req.Value {
return binder.WriteResponse(c, http.StatusOK, tailcfg.SetDNSResponse{})
}
}
case <-timeout:
return binder.WriteResponse(c, http.StatusOK, tailcfg.SetDNSResponse{})
case <-notify:
return nil
}
}
}
return binder.WriteResponse(c, http.StatusOK, tailcfg.SetDNSResponse{})
}
+60
View File
@@ -0,0 +1,60 @@
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 {
return func(ctx echo.Context) bool {
if ctx.Request().Method == "POST" && ctx.Request().RequestURI == "/ts2021" {
return true
}
return !c.ForceHttps
}
}
func HttpsRedirect(c config.Tls) echo.MiddlewareFunc {
return middleware.HTTPSRedirectWithConfig(middleware.RedirectConfig{
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
}
+149
View File
@@ -0,0 +1,149 @@
package handlers
import (
"fmt"
"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"
"time"
)
func NewIDTokenHandlers(createBinder bind.Factory, config *config.Config, repository domain.Repository) *IDTokenHandlers {
return &IDTokenHandlers{
issuer: config.ServerUrl,
jwksUri: config.CreateUrl("/.well-known/jwks"),
createBinder: createBinder,
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)
}
func (h *IDTokenHandlers) FetchToken(c echo.Context) error {
ctx := c.Request().Context()
keySet, err := h.repository.GetJSONWebKeySet(c.Request().Context())
if err != nil {
return errors.Wrap(err, 0)
}
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()
nodeKey := req.NodeKey.String()
var m *domain.Machine
m, err = h.repository.GetMachineByKeys(ctx, machineKey, nodeKey)
if err != nil {
return errors.Wrap(err, 0)
}
if m == nil {
return echo.NewHTTPError(http.StatusBadRequest)
}
_, tailnetDomain, sub := h.names(m)
now := time.Now()
claims := jwt.MapClaims{
"jit": fmt.Sprintf("%d", util.NextID()),
"iss": h.issuer,
"sub": sub,
"aud": []string{req.Audience},
"exp": jwt.NewNumericDate(now.Add(5 * time.Minute)),
"nbf": jwt.NewNumericDate(now),
"iat": jwt.NewNumericDate(now),
"key": m.NodeKey,
"addresses": []string{m.IPv4.String(), m.IPv6.String()},
"nid": m.ID,
"node": sub,
"domain": tailnetDomain,
}
if m.HasTags() {
tags := []string{}
for _, t := range m.Tags {
tags = append(tags, fmt.Sprintf("%s:%s", tailnetDomain, t))
}
claims["tags"] = tags
} else {
claims["user"] = fmt.Sprintf("%s:%s", tailnetDomain, m.User.Name)
claims["uid"] = m.UserID
}
unsignedToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
unsignedToken.Header["kid"] = keySet.Key.Id
jwtB64, err := unsignedToken.SignedString(&keySet.Key.PrivateKey)
if err != nil {
return errors.Wrap(err, 0)
}
resp := tailcfg.TokenResponse{IDToken: jwtB64}
return binder.WriteResponse(c, http.StatusOK, resp)
}
func (h *IDTokenHandlers) names(m *domain.Machine) (string, string, string) {
var name = m.Name
if m.NameIdx != 0 {
name = fmt.Sprintf("%s-%d", m.Name, m.NameIdx)
}
sanitizedTailnetName := domain.SanitizeTailnetName(m.Tailnet.Name)
return name, sanitizedTailnetName, fmt.Sprintf("%s.%s", name, sanitizedTailnetName)
}
+2 -2
View File
@@ -1,7 +1,6 @@
package handlers
import (
"fmt"
"github.com/jsiebens/ionscale/internal/version"
"github.com/labstack/echo/v4"
)
@@ -10,7 +9,8 @@ func IndexHandler(code int) echo.HandlerFunc {
return func(c echo.Context) error {
info, s := version.GetReleaseInfo()
data := map[string]interface{}{
"Version": fmt.Sprintf("%s - %s", info, s),
"Version": info,
"Revision": s,
}
return c.Render(code, "index.html", data)
}
+1 -1
View File
@@ -22,7 +22,7 @@ func KeyHandler(keys *config.ServerKeys) echo.HandlerFunc {
if v != "" {
clientCapabilityVersion, err := strconv.Atoi(v)
if err != nil {
return c.String(http.StatusBadRequest, "Invalid version")
return echo.NewHTTPError(http.StatusBadRequest, "Invalid version")
}
if clientCapabilityVersion >= NoiseCapabilityVersion {
+16
View File
@@ -0,0 +1,16 @@
package handlers
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const prometheusNamespace = "ionscale"
var (
connectedDevices = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: prometheusNamespace,
Name: "connected_machines_total",
Help: "Total amount of connected machines",
}, []string{"tailnet"})
)
+9 -4
View File
@@ -1,10 +1,12 @@
package handlers
import (
"context"
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/net/netutil"
@@ -26,14 +28,17 @@ func NewNoiseHandlers(controlKey key.MachinePrivate, createPeerHandler CreatePee
}
func (h *NoiseHandlers) Upgrade(c echo.Context) error {
conn, err := controlhttp.AcceptHTTP(context.Background(), c.Response(), c.Request(), h.controlKey)
conn, err := controlhttp.AcceptHTTP(c.Request().Context(), c.Response(), c.Request(), h.controlKey)
if err != nil {
return err
return errors.Wrap(err, 0)
}
handler := h.createPeerHandler(conn.Peer())
server := http.Server{}
server.Handler = h2c.NewHandler(handler, &http2.Server{})
return server.Serve(netutil.NewOneConnListener(conn, nil))
if err := server.Serve(netutil.NewOneConnListener(conn, nil)); err != nil && !stderrors.Is(err, io.EOF) {
return err
}
return nil
}
+132 -136
View File
@@ -3,53 +3,49 @@ package handlers
import (
"context"
"github.com/jsiebens/ionscale/internal/bind"
"github.com/jsiebens/ionscale/internal/broker"
"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/labstack/echo/v4"
"net/http"
"net/netip"
"tailscale.com/tailcfg"
"tailscale.com/util/dnsname"
"tailscale.com/types/opt"
"time"
)
const (
keepAliveInterval = 1 * time.Minute
)
func NewPollNetMapHandler(
createBinder bind.Factory,
brokers *broker.BrokerPool,
repository domain.Repository,
offlineTimers *OfflineTimers) *PollNetMapHandler {
sessionManager core.PollMapSessionManager,
repository domain.Repository) *PollNetMapHandler {
handler := &PollNetMapHandler{
createBinder: createBinder,
brokers: brokers.Get,
repository: repository,
offlineTimers: offlineTimers,
createBinder: createBinder,
sessionManager: sessionManager,
repository: repository,
}
return handler
}
type PollNetMapHandler struct {
createBinder bind.Factory
repository domain.Repository
brokers func(uint64) broker.Broker
offlineTimers *OfflineTimers
createBinder bind.Factory
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 err
return errors.Wrap(err, 0)
}
req := &tailcfg.MapRequest{}
if err := binder.BindRequest(c, req); err != nil {
return err
return errors.Wrap(err, 0)
}
machineKey := binder.Peer().String()
@@ -58,7 +54,7 @@ func (h *PollNetMapHandler) PollNetMap(c echo.Context) error {
var m *domain.Machine
m, err = h.repository.GetMachineByKeys(ctx, machineKey, nodeKey)
if err != nil {
return err
return errors.Wrap(err, 0)
}
if m == nil {
@@ -83,70 +79,68 @@ func (h *PollNetMapHandler) handleUpdate(c echo.Context, binder bind.Binder, m *
m.LastSeen = &now
if err := h.repository.SaveMachine(ctx, m); err != nil {
return err
return errors.Wrap(err, 0)
}
tailnetID := m.TailnetID
machineID := m.ID
tailnetBroker := h.brokers(tailnetID)
tailnetBroker.SignalPeerUpdated(machineID)
h.sessionManager.NotifyAll(tailnetID)
if !mapRequest.Stream {
return c.String(http.StatusOK, "")
}
var syncedPeers = make(map[uint64]bool)
var derpMapChecksum = ""
response, syncedPeers, err := h.createMapResponse(m, binder, mapRequest, false, make(map[uint64]bool))
response, syncedPeers, derpMapChecksum, err := h.createMapResponse(m, binder, mapRequest, false, make(map[uint64]bool), derpMapChecksum)
if err != nil {
return err
return errors.Wrap(err, 0)
}
updateChan := make(chan *broker.Signal, 20)
client := broker.NewClient(machineID, updateChan)
updateChan := make(chan *core.Ping, 20)
h.sessionManager.Register(m.TailnetID, m.ID, updateChan)
tailnetBroker.AddClient(&client)
h.cancelOfflineMessage(machineID)
// Listen to connection close and un-register messageChan
// Listen to connection close
notify := c.Request().Context().Done()
keepAliveResponse, err := h.createKeepAliveResponse(binder, mapRequest)
if err != nil {
return err
return errors.Wrap(err, 0)
}
keepAliveTicker := time.NewTicker(keepAliveInterval)
syncTicker := time.NewTicker(5 * time.Second)
var latestSync = time.Now()
var latestUpdate = latestSync
c.Response().WriteHeader(http.StatusOK)
if _, err := c.Response().Write(response); err != nil {
return err
return errors.Wrap(err, 0)
}
c.Response().Flush()
connectedDevices.WithLabelValues(m.Tailnet.Name).Inc()
keepAliveTicker := time.NewTicker(config.KeepAliveInterval())
syncTicker := time.NewTicker(5 * time.Second)
defer func() {
tailnetBroker.RemoveClient(machineID)
connectedDevices.WithLabelValues(m.Tailnet.Name).Dec()
h.sessionManager.Deregister(m.TailnetID, m.ID)
keepAliveTicker.Stop()
syncTicker.Stop()
_ = h.repository.SetMachineLastSeen(ctx, machineID)
h.scheduleOfflineMessage(tailnetID, machineID)
}()
var latestSync = time.Now()
var latestUpdate = latestSync
for {
select {
case s := <-updateChan:
if s.PeerUpdated == nil || *s.PeerUpdated != machineID {
latestUpdate = time.Now()
}
case <-updateChan:
latestUpdate = time.Now()
case <-keepAliveTicker.C:
if mapRequest.KeepAlive {
if _, err := c.Response().Write(keepAliveResponse); err != nil {
return err
return errors.Wrap(err, 0)
}
_ = h.repository.SetMachineLastSeen(ctx, machineID)
c.Response().Flush()
@@ -155,7 +149,7 @@ func (h *PollNetMapHandler) handleUpdate(c echo.Context, binder bind.Binder, m *
if latestSync.Before(latestUpdate) {
machine, err := h.repository.GetMachine(ctx, machineID)
if err != nil {
return err
return errors.Wrap(err, 0)
}
if machine == nil {
return nil
@@ -164,14 +158,14 @@ func (h *PollNetMapHandler) handleUpdate(c echo.Context, binder bind.Binder, m *
var payload []byte
var payloadErr error
payload, syncedPeers, payloadErr = h.createMapResponse(machine, binder, mapRequest, true, syncedPeers)
payload, syncedPeers, derpMapChecksum, payloadErr = h.createMapResponse(machine, binder, mapRequest, true, syncedPeers, derpMapChecksum)
if payloadErr != nil {
return payloadErr
}
if _, err := c.Response().Write(payload); err != nil {
return err
return errors.Wrap(err, 0)
}
c.Response().Flush()
@@ -190,24 +184,16 @@ func (h *PollNetMapHandler) handleReadOnly(c echo.Context, binder bind.Binder, m
m.DiscoKey = request.DiscoKey.String()
if err := h.repository.SaveMachine(ctx, m); err != nil {
return err
return errors.Wrap(err, 0)
}
response, _, err := h.createMapResponse(m, binder, request, false, map[uint64]bool{})
response, _, _, err := h.createMapResponse(m, binder, request, false, map[uint64]bool{}, "")
if err != nil {
return err
return errors.Wrap(err, 0)
}
_, err = c.Response().Write(response)
return err
}
func (h *PollNetMapHandler) scheduleOfflineMessage(tailnetID, machineID uint64) {
h.offlineTimers.startCh <- [2]uint64{tailnetID, machineID}
}
func (h *PollNetMapHandler) cancelOfflineMessage(machineID uint64) {
h.offlineTimers.stopCh <- machineID
return errors.Wrap(err, 0)
}
func (h *PollNetMapHandler) createKeepAliveResponse(binder bind.Binder, request *tailcfg.MapRequest) ([]byte, error) {
@@ -218,70 +204,109 @@ func (h *PollNetMapHandler) createKeepAliveResponse(binder bind.Binder, request
return binder.Marshal(request.Compress, mapResponse)
}
func (h *PollNetMapHandler) createMapResponse(m *domain.Machine, binder bind.Binder, request *tailcfg.MapRequest, delta bool, prevSyncedPeerIDs map[uint64]bool) ([]byte, map[uint64]bool, error) {
node, err := mapping.ToNode(m, true)
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)
if err != nil {
return nil, nil, err
return nil, nil, "", err
}
users, err := h.repository.ListUsers(context.TODO(), m.TailnetID)
hostinfo := tailcfg.Hostinfo(m.HostInfo)
node, user, err := mapping.ToNode(m, tailnet, false, true, prc.filter)
if err != nil {
return nil, nil, err
return nil, nil, "", err
}
policies := tailnet.ACLPolicy
var users = []tailcfg.UserProfile{*user}
var changedPeers []*tailcfg.Node
var removedPeers []tailcfg.NodeID
candidatePeers, err := h.repository.ListMachinePeers(context.TODO(), m.TailnetID, m.MachineKey)
candidatePeers, err := h.repository.ListMachinePeers(ctx, m.TailnetID, m.MachineKey)
if err != nil {
return nil, nil, err
return nil, nil, "", err
}
syncedPeerIDs := map[uint64]bool{}
syncedUserIDs := map[tailcfg.UserID]bool{}
for _, peer := range candidatePeers {
n, err := mapping.ToNode(&peer, h.brokers(peer.TailnetID).IsConnected(peer.ID))
if err != nil {
return nil, nil, err
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
}
}
changedPeers = append(changedPeers, n)
syncedPeerIDs[peer.ID] = true
delete(prevSyncedPeerIDs, peer.ID)
}
for p, _ := range prevSyncedPeerIDs {
removedPeers = append(removedPeers, tailcfg.NodeID(p))
}
derpMap, err := h.repository.GetDERPMap(context.TODO())
dnsConfig := tailnet.DNSConfig
derpMap, err := m.Tailnet.GetDERPMap(ctx, h.repository)
if err != nil {
return nil, nil, err
return nil, nil, "", err
}
rules := tailcfg.FilterAllowAll
filterRules := policies.BuildFilterRules(candidatePeers, m)
controlTime := time.Now().UTC()
var mapResponse *tailcfg.MapResponse
if !delta {
mapResponse = &tailcfg.MapResponse{
KeepAlive: false,
Node: node,
PacketFilter: rules,
DERPMap: derpMap,
Domain: dnsname.SanitizeHostname(m.Tailnet.Name),
Peers: changedPeers,
UserProfiles: mapping.ToUserProfiles(users),
ControlTime: &controlTime,
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,
},
}
} else {
mapResponse = &tailcfg.MapResponse{
PacketFilter: rules,
PeersChanged: changedPeers,
PeersRemoved: removedPeers,
UserProfiles: mapping.ToUserProfiles(users),
ControlTime: &controlTime,
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
}
}
if tailnet.SSHEnabled && hostinfo.TailscaleSSHEnabled() {
mapResponse.SSHPolicy = policies.BuildSSHPolicy(candidatePeers, m)
}
if request.OmitPeers {
@@ -292,61 +317,32 @@ func (h *PollNetMapHandler) createMapResponse(m *domain.Machine, binder bind.Bin
payload, err := binder.Marshal(request.Compress, mapResponse)
return payload, syncedPeerIDs, nil
return payload, syncedPeerIDs, derpMap.Checksum, nil
}
func NewOfflineTimers(repository domain.Repository, brokers *broker.BrokerPool) *OfflineTimers {
return &OfflineTimers{
repository: repository,
brokers: brokers.Get,
data: make(map[uint64]*time.Timer),
startCh: make(chan [2]uint64),
stopCh: make(chan uint64),
}
func optBool(v bool) opt.Bool {
b := opt.Bool("")
b.Set(v)
return b
}
type OfflineTimers struct {
repository domain.Repository
brokers func(uint64) broker.Broker
data map[uint64]*time.Timer
stopCh chan uint64
startCh chan [2]uint64
type primaryRoutesCollector struct {
flagged map[netip.Prefix]bool
}
func (o *OfflineTimers) Start() {
for {
select {
case i := <-o.startCh:
o.scheduleOfflineMessage(i[0], i[1])
case m := <-o.stopCh:
o.cancelOfflineMessage(m)
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
}
}
}
func (o *OfflineTimers) scheduleOfflineMessage(tailnetID, machineID uint64) {
t, ok := o.data[machineID]
if ok {
t.Stop()
delete(o.data, machineID)
}
timer := time.NewTimer(10 * time.Second)
go func() {
<-timer.C
if !o.brokers(tailnetID).IsConnected(machineID) {
o.brokers(tailnetID).SignalPeerUpdated(machineID)
o.stopCh <- machineID
for _, r := range m.AutoAllowIPs {
if _, ok := p.flagged[r]; r.Bits() != 0 && !ok {
result = append(result, r)
p.flagged[r] = true
}
}()
o.data[machineID] = timer
}
func (o *OfflineTimers) cancelOfflineMessage(machineID uint64) {
t, ok := o.data[machineID]
if ok {
t.Stop()
delete(o.data, machineID)
}
return result
}
+147 -69
View File
@@ -5,12 +5,14 @@ import (
"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"
"github.com/patrickmn/go-cache"
"inet.af/netaddr"
"net/http"
"net/netip"
"tailscale.com/tailcfg"
"tailscale.com/util/dnsname"
"time"
@@ -19,26 +21,21 @@ import (
func NewRegistrationHandlers(
createBinder bind.Factory,
config *config.Config,
repository domain.Repository,
pendingMachineRegistrationRequests *cache.Cache) *RegistrationHandlers {
sessionManager core.PollMapSessionManager,
repository domain.Repository) *RegistrationHandlers {
return &RegistrationHandlers{
createBinder: createBinder,
repository: repository,
config: config,
pendingMachineRegistrationRequests: pendingMachineRegistrationRequests,
createBinder: createBinder,
sessionManager: sessionManager,
repository: repository,
config: config,
}
}
type pendingMachineRegistrationRequest struct {
machineKey string
request *tailcfg.RegisterRequest
}
type RegistrationHandlers struct {
createBinder bind.Factory
repository domain.Repository
config *config.Config
pendingMachineRegistrationRequests *cache.Cache
createBinder bind.Factory
repository domain.Repository
sessionManager core.PollMapSessionManager
config *config.Config
}
func (h *RegistrationHandlers) Register(c echo.Context) error {
@@ -46,12 +43,12 @@ func (h *RegistrationHandlers) Register(c echo.Context) error {
binder, err := h.createBinder(c)
if err != nil {
return err
return errors.Wrap(err, 0)
}
req := &tailcfg.RegisterRequest{}
if err := binder.BindRequest(c, req); err != nil {
return err
return errors.Wrap(err, 0)
}
machineKey := binder.Peer().String()
@@ -61,20 +58,28 @@ func (h *RegistrationHandlers) Register(c echo.Context) error {
m, err = h.repository.GetMachineByKeys(ctx, machineKey, nodeKey)
if err != nil {
return err
return errors.Wrap(err, 0)
}
if m != nil {
if m.ExpiresAt != nil && !m.ExpiresAt.IsZero() && m.ExpiresAt.Before(time.Now()) {
if m.IsExpired() {
response := tailcfg.RegisterResponse{NodeKeyExpired: true}
return binder.WriteResponse(c, http.StatusOK, response)
}
if !req.Expiry.IsZero() && req.Expiry.Before(time.Now()) {
m.ExpiresAt = &req.Expiry
m.ExpiresAt = req.Expiry
if err := h.repository.SaveMachine(ctx, m); err != nil {
return err
if m.Ephemeral {
if _, err := h.repository.DeleteMachine(ctx, m.ID); err != nil {
return errors.Wrap(err, 0)
}
h.sessionManager.NotifyAll(m.TailnetID)
} else {
if err := h.repository.SaveMachine(ctx, m); err != nil {
return errors.Wrap(err, 0)
}
h.sessionManager.NotifyAll(m.TailnetID)
}
response := tailcfg.RegisterResponse{NodeKeyExpired: true}
@@ -85,7 +90,7 @@ func (h *RegistrationHandlers) Register(c echo.Context) error {
if m.Name != sanitizeHostname {
nameIdx, err := h.repository.GetNextMachineNameIndex(ctx, m.TailnetID, sanitizeHostname)
if err != nil {
return err
return errors.Wrap(err, 0)
}
m.Name = sanitizeHostname
m.NameIdx = nameIdx
@@ -96,35 +101,51 @@ func (h *RegistrationHandlers) Register(c echo.Context) error {
m.Tags = append(m.RegisteredTags, advertisedTags...)
if err := h.repository.SaveMachine(ctx, m); err != nil {
return err
return errors.Wrap(err, 0)
}
tUser, tLogin := mapping.ToUser(m.User)
response := tailcfg.RegisterResponse{
MachineAuthorized: m.Authorized,
User: tUser,
Login: tLogin,
}
response := tailcfg.RegisterResponse{MachineAuthorized: true}
return binder.WriteResponse(c, http.StatusOK, response)
}
return h.authenticateMachine(c, binder, machineKey, req)
}
func (h *RegistrationHandlers) authenticateMachine(c echo.Context, binder bind.Binder, id string, req *tailcfg.RegisterRequest) error {
func (h *RegistrationHandlers) authenticateMachine(c echo.Context, binder bind.Binder, machineKey string, req *tailcfg.RegisterRequest) error {
ctx := c.Request().Context()
if req.Followup != "" {
response := tailcfg.RegisterResponse{AuthURL: req.Followup}
return binder.WriteResponse(c, http.StatusOK, response)
return h.followup(c, binder, req)
}
if req.Auth.AuthKey == "" {
key := util.RandStringBytes(8)
authUrl := h.config.CreateUrl("/a/%s", key)
authUrl := h.config.CreateUrl("/a/r/%s", key)
h.pendingMachineRegistrationRequests.Set(key, &pendingMachineRegistrationRequest{
machineKey: id,
request: req,
}, cache.DefaultExpiration)
request := domain.RegistrationRequest{
MachineKey: machineKey,
Key: key,
CreatedAt: time.Now().UTC(),
Data: domain.RegistrationRequestData(*req),
}
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)
}
response := tailcfg.RegisterResponse{AuthURL: authUrl}
return binder.WriteResponse(c, http.StatusOK, response)
} else {
return h.authenticateMachineWithAuthKey(c, binder, id, req)
return h.authenticateMachineWithAuthKey(c, binder, machineKey, req)
}
}
@@ -134,96 +155,153 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, bi
authKey, err := h.repository.LoadAuthKey(ctx, req.Auth.AuthKey)
if err != nil {
return err
return errors.Wrap(err, 0)
}
if authKey == nil {
return c.String(http.StatusBadRequest, "invalid auth key")
response := tailcfg.RegisterResponse{MachineAuthorized: false, Error: "invalid auth key"}
return binder.WriteResponse(c, http.StatusOK, response)
}
tailnet := authKey.Tailnet
user := authKey.User
if err := tailnet.ACLPolicy.CheckTagOwners(req.Hostinfo.RequestTags, &user); err != nil {
response := tailcfg.RegisterResponse{MachineAuthorized: false, Error: err.Error()}
return binder.WriteResponse(c, 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)
var m *domain.Machine
m, err = h.repository.GetMachineByKey(ctx, tailnet.ID, machineKey)
if err != nil {
return err
return errors.Wrap(err, 0)
}
now := time.Now().UTC()
if m == nil {
now := time.Now().UTC()
registeredTags := authKey.Tags
advertisedTags := domain.SanitizeTags(req.Hostinfo.RequestTags)
tags := append(registeredTags, advertisedTags...)
sanitizeHostname := dnsname.SanitizeHostname(req.Hostinfo.Hostname)
nameIdx, err := h.repository.GetNextMachineNameIndex(ctx, tailnet.ID, sanitizeHostname)
if err != nil {
return err
return errors.Wrap(err, 0)
}
m = &domain.Machine{
ID: util.NextID(),
Name: sanitizeHostname,
NameIdx: nameIdx,
MachineKey: machineKey,
NodeKey: nodeKey,
Ephemeral: authKey.Ephemeral,
RegisteredTags: registeredTags,
Tags: domain.SanitizeTags(tags),
CreatedAt: now,
ID: util.NextID(),
Name: sanitizeHostname,
NameIdx: nameIdx,
MachineKey: machineKey,
NodeKey: nodeKey,
Ephemeral: authKey.Ephemeral || req.Ephemeral,
RegisteredTags: registeredTags,
Tags: domain.SanitizeTags(tags),
AutoAllowIPs: autoAllowIPs,
CreatedAt: now,
ExpiresAt: now.Add(180 * 24 * time.Hour).UTC(),
KeyExpiryDisabled: len(tags) != 0,
Authorized: !tailnet.MachineAuthorizationEnabled || authKey.PreAuthorized,
User: user,
Tailnet: tailnet,
}
if !req.Expiry.IsZero() {
m.ExpiresAt = &req.Expiry
m.ExpiresAt = req.Expiry
}
ipv4, ipv6, err := addr.SelectIP(checkIP(ctx, h.repository.CountMachinesWithIPv4))
if err != nil {
return err
return errors.Wrap(err, 0)
}
m.IPv4 = ipv4.String()
m.IPv6 = ipv6.String()
m.IPv4 = domain.IP{Addr: ipv4}
m.IPv6 = domain.IP{Addr: ipv6}
} else {
registeredTags := authKey.Tags
advertisedTags := domain.SanitizeTags(req.Hostinfo.RequestTags)
tags := append(registeredTags, advertisedTags...)
sanitizeHostname := dnsname.SanitizeHostname(req.Hostinfo.Hostname)
if m.Name != sanitizeHostname {
nameIdx, err := h.repository.GetNextMachineNameIndex(ctx, tailnet.ID, sanitizeHostname)
if err != nil {
return err
return errors.Wrap(err, 0)
}
m.Name = sanitizeHostname
m.NameIdx = nameIdx
}
m.NodeKey = nodeKey
m.Ephemeral = authKey.Ephemeral
m.Ephemeral = authKey.Ephemeral || req.Ephemeral
m.RegisteredTags = registeredTags
m.Tags = domain.SanitizeTags(tags)
m.AutoAllowIPs = autoAllowIPs
m.UserID = user.ID
m.User = user
m.TailnetID = tailnet.ID
m.Tailnet = tailnet
m.ExpiresAt = nil
m.ExpiresAt = now.Add(180 * 24 * time.Hour).UTC()
}
if err := h.repository.SaveMachine(ctx, m); err != nil {
return err
return errors.Wrap(err, 0)
}
tUser, tLogin := mapping.ToUser(m.User)
response := tailcfg.RegisterResponse{
MachineAuthorized: true,
User: tUser,
Login: tLogin,
}
response := tailcfg.RegisterResponse{MachineAuthorized: true}
return binder.WriteResponse(c, http.StatusOK, response)
}
func (h *RegistrationHandlers) followup(c echo.Context, binder bind.Binder, req *tailcfg.RegisterRequest) error {
// Listen to connection close
ctx := c.Request().Context()
notify := ctx.Done()
tick := time.NewTicker(2 * time.Second)
defer func() { tick.Stop() }()
machineKey := binder.Peer().String()
for {
select {
case <-tick.C:
m, err := h.repository.GetRegistrationRequestByMachineKey(ctx, machineKey)
if err != nil || m == nil {
response := tailcfg.RegisterResponse{MachineAuthorized: false, Error: "something went wrong"}
return binder.WriteResponse(c, http.StatusOK, response)
}
if m != nil && m.IsFinished() {
user, err := h.repository.GetUser(ctx, m.UserID)
if err != nil {
return err
}
u, l := mapping.ToUser(*user)
response := tailcfg.RegisterResponse{
MachineAuthorized: len(m.Error) != 0,
Error: m.Error,
User: u,
Login: l,
}
return binder.WriteResponse(c, http.StatusOK, response)
}
case <-notify:
return nil
}
}
}
func checkIP(cxt context.Context, s Selector) addr.Predicate {
return func(ip netaddr.IP) (bool, error) {
return func(ip netip.Addr) (bool, error) {
c, err := s(cxt, ip.String())
if err != nil {
return false, err
+114
View File
@@ -0,0 +1,114 @@
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"
"time"
)
func NewSSHActionHandlers(createBinder bind.Factory, config *config.Config, repository domain.Repository) *SSHActionHandlers {
return &SSHActionHandlers{
createBinder: createBinder,
repository: repository,
config: config,
}
}
type SSHActionHandlers struct {
createBinder bind.Factory
repository domain.Repository
config *config.Config
}
type sshActionRequestData struct {
SrcMachineID uint64 `param:"src_machine_id"`
DstMachineID uint64 `param:"dst_machine_id"`
}
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)
}
key := util.RandStringBytes(8)
request := &domain.SSHActionRequest{
Key: key,
SrcMachineID: data.SrcMachineID,
DstMachineID: data.DstMachineID,
CreatedAt: time.Now().UTC(),
}
authUrl := h.config.CreateUrl("/a/s/%s", key)
if err := h.repository.SaveSSHActionRequest(ctx, request); err != nil {
return errors.Wrap(err, 0)
}
resp := &tailcfg.SSHAction{
Message: fmt.Sprintf("# Tailscale SSH requires an additional check.\n# To authenticate, visit: %s\n", authUrl),
HoldAndDelegate: fmt.Sprintf("https://unused/machine/ssh/action/check/%s", key),
}
return binder.WriteResponse(c, http.StatusOK, resp)
}
func (h *SSHActionHandlers) CheckAuth(c echo.Context) error {
// Listen to connection close
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() }()
key := c.Param("key")
for {
select {
case <-tick.C:
m, err := h.repository.GetSSHActionRequest(ctx, key)
if err != nil || m == nil {
return binder.WriteResponse(c, http.StatusOK, &tailcfg.SSHAction{Reject: true})
}
if m.Action == "accept" {
action := &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
}
_ = h.repository.DeleteSSHActionRequest(ctx, key)
return binder.WriteResponse(c, http.StatusOK, action)
}
if m.Action == "reject" {
action := &tailcfg.SSHAction{Reject: true}
_ = h.repository.DeleteSSHActionRequest(ctx, key)
return binder.WriteResponse(c, http.StatusOK, action)
}
case <-notify:
return nil
}
}
}
+119
View File
@@ -0,0 +1,119 @@
package key
import (
crand "crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/nacl/box"
"io"
)
func NewServerKey() ServerPrivate {
_, key, err := box.GenerateKey(crand.Reader)
if err != nil {
panic(fmt.Sprintf("unable create new key: %v", err))
}
return ServerPrivate{k: *key}
}
func ParsePrivateKey(key string) (*ServerPrivate, error) {
k := new([32]byte)
err := parseHex(k[:], key)
if err != nil {
return nil, err
}
return &ServerPrivate{k: *k}, nil
}
func ParsePublicKey(key string) (*ServerPublic, error) {
k := new([32]byte)
err := parseHex(k[:], key)
if err != nil {
return nil, err
}
return &ServerPublic{k: *k}, nil
}
func parseHex(out []byte, v string) error {
in := []byte(v)
if want := len(out) * 2; len(in) != want {
return fmt.Errorf("key hex has the wrong size, got %d want %d", len(in), want)
}
_, err := hex.Decode(out[:], in)
if err != nil {
return err
}
return nil
}
type ServerPrivate struct {
k [32]byte
}
type ServerPublic struct {
k [32]byte
}
func (k ServerPrivate) Public() ServerPublic {
var ret ServerPublic
curve25519.ScalarBaseMult(&ret.k, &k.k)
return ret
}
func (k ServerPrivate) Equal(other ServerPrivate) bool {
return subtle.ConstantTimeCompare(k.k[:], other.k[:]) == 1
}
func (k ServerPrivate) IsZero() bool {
return k.Equal(ServerPrivate{})
}
func (k ServerPrivate) Seal(cleartext []byte) (ciphertext []byte) {
if k.IsZero() {
panic("can't seal with zero keys")
}
var nonce [24]byte
rand(nonce[:])
p := k.Public()
return box.Seal(nonce[:], cleartext, &nonce, &p.k, &k.k)
}
func (k ServerPrivate) Open(ciphertext []byte) (cleartext []byte, ok bool) {
if k.IsZero() {
panic("can't open with zero keys")
}
if len(ciphertext) < 24 {
return nil, false
}
var nonce [24]byte
copy(nonce[:], ciphertext)
p := k.Public()
return box.Open(nil, ciphertext[len(nonce):], &nonce, &p.k, &k.k)
}
func (k ServerPrivate) String() string {
return hex.EncodeToString(k.k[:])
}
func (k ServerPublic) Equal(other ServerPublic) bool {
return subtle.ConstantTimeCompare(k.k[:], other.k[:]) == 1
}
func (k ServerPublic) IsZero() bool {
return k.Equal(ServerPublic{})
}
func (k ServerPublic) String() string {
return hex.EncodeToString(k.k[:])
}
func rand(b []byte) {
if _, err := io.ReadFull(crand.Reader, b[:]); err != nil {
panic(fmt.Sprintf("unable to read random bytes from OS: %v", err))
}
}
+158 -35
View File
@@ -1,34 +1,121 @@
package mapping
import (
"encoding/json"
"fmt"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/util"
"inet.af/netaddr"
"net/netip"
"strconv"
"tailscale.com/tailcfg"
"tailscale.com/types/dnstype"
"tailscale.com/types/key"
"tailscale.com/util/dnsname"
"time"
)
const NetworkMagicDNSSuffix = "ionscale.net"
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{}
for _, r := range c.Nameservers {
resolver := &dnstype.Resolver{
Addr: r,
}
resolvers = append(resolvers, resolver)
}
dnsConfig := &tailcfg.DNSConfig{}
var domains []string
var certDomains []string
if c.MagicDNS {
domains = append(domains, tailnetDomain)
dnsConfig.Proxied = true
if certsEnabled {
certDomains = append(certDomains, fmt.Sprintf("%s.%s", m.CompleteName(), tailnetDomain))
}
}
if c.OverrideLocalDNS {
dnsConfig.Resolvers = resolvers
} else {
dnsConfig.FallbackResolvers = resolvers
}
if len(c.Routes) != 0 || certsEnabled {
routes := make(map[string][]*dnstype.Resolver)
for r, s := range c.Routes {
routeResolver := []*dnstype.Resolver{}
for _, addr := range s {
resolver := &dnstype.Resolver{Addr: addr}
routeResolver = append(routeResolver, resolver)
}
routes[r] = routeResolver
domains = append(domains, r)
}
dnsConfig.Routes = routes
}
dnsConfig.Domains = domains
dnsConfig.CertDomains = certDomains
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(m *domain.Machine, connected bool) (*tailcfg.Node, error) {
nKey, err := util.ParseNodePublicKey(m.NodeKey)
if err != nil {
return nil, err
return nil, nil, err
}
mKey, err := util.ParseMachinePublicKey(m.MachineKey)
if err != nil {
return nil, err
return nil, nil, err
}
var discoKey key.DiscoPublic
if m.DiscoKey != "" {
dKey, err := util.ParseDiscoPublicKey(m.DiscoKey)
if err != nil {
return nil, err
return nil, nil, err
}
discoKey = *dKey
}
@@ -36,27 +123,31 @@ func ToNode(m *domain.Machine, connected bool) (*tailcfg.Node, error) {
endpoints := m.Endpoints
hostinfo := tailcfg.Hostinfo(m.HostInfo)
var addrs []netaddr.IPPrefix
var allowedIPs []netaddr.IPPrefix
var addrs []netip.Prefix
var allowedIPs []netip.Prefix
if m.IPv4 != "" {
ipv4, err := netaddr.ParseIPPrefix(fmt.Sprintf("%s/32", m.IPv4))
if m.IPv4.IsValid() {
ipv4, err := m.IPv4.Prefix(32)
if err != nil {
return nil, err
return nil, nil, err
}
addrs = append(addrs, ipv4)
allowedIPs = append(allowedIPs, ipv4)
}
if m.IPv6 != "" {
ipv6, err := netaddr.ParseIPPrefix(fmt.Sprintf("%s/128", m.IPv6))
if m.IPv6.IsValid() {
ipv6, err := m.IPv6.Prefix(128)
if err != nil {
return nil, err
return nil, nil, err
}
addrs = append(addrs, ipv6)
allowedIPs = append(allowedIPs, ipv6)
}
if connected {
allowedIPs = append(allowedIPs, routeFilter(m)...)
}
var derp string
if hostinfo.NetInfo != nil {
derp = fmt.Sprintf("127.3.3.40:%d", hostinfo.NetInfo.PreferredDERP)
@@ -64,23 +155,20 @@ func ToNode(m *domain.Machine, connected bool) (*tailcfg.Node, error) {
derp = "127.3.3.40:0"
}
var name = m.Name
if m.NameIdx != 0 {
name = fmt.Sprintf("%s-%d", m.Name, m.NameIdx)
}
var name = m.CompleteName()
sanitizedTailnetName := dnsname.SanitizeHostname(m.Tailnet.Name)
sanitizedTailnetName := domain.SanitizeTailnetName(m.Tailnet.Name)
hostInfo := tailcfg.Hostinfo{
OS: hostinfo.OS,
Hostname: hostinfo.Hostname,
Services: hostinfo.Services,
Services: filterServices(hostinfo.Services),
}
n := tailcfg.Node{
ID: tailcfg.NodeID(m.ID),
StableID: tailcfg.StableNodeID(strconv.FormatUint(m.ID, 10)),
Name: fmt.Sprintf("%s.%s.%s.", name, sanitizedTailnetName, NetworkMagicDNSSuffix),
Name: fmt.Sprintf("%s.%s.%s.", name, sanitizedTailnetName, config.MagicDNSSuffix()),
Key: *nKey,
Machine: *mKey,
DiscoKey: discoKey,
@@ -89,26 +177,41 @@ func ToNode(m *domain.Machine, connected bool) (*tailcfg.Node, error) {
Endpoints: endpoints,
DERP: derp,
Hostinfo: hostInfo.View(),
Hostinfo: hostInfo.View(),
Capabilities: capabilities,
Created: m.CreatedAt.UTC(),
MachineAuthorized: true,
MachineAuthorized: m.Authorized,
User: tailcfg.UserID(m.UserID),
}
if m.ExpiresAt != nil {
if !m.ExpiresAt.IsZero() {
e := m.ExpiresAt.UTC()
n.KeyExpiry = e
}
n.Online = &connected
if !connected && m.LastSeen != nil {
l := m.LastSeen.UTC()
n.LastSeen = &l
if m.KeyExpiryDisabled {
n.KeyExpiry = time.Time{}
}
return &n, nil
n.Online = &connected
if !connected && m.LastSeen != nil {
n.LastSeen = m.LastSeen
}
var user = ToUserProfile(m.User)
if m.HasTags() {
n.User = tailcfg.UserID(m.ID)
user = tailcfg.UserProfile{
ID: tailcfg.UserID(m.ID),
LoginName: "tagged-devices",
DisplayName: "Tagged Devices",
}
}
return &n, &user, nil
}
func ToUserProfile(u domain.User) tailcfg.UserProfile {
@@ -120,10 +223,30 @@ func ToUserProfile(u domain.User) tailcfg.UserProfile {
return profile
}
func ToUserProfiles(users domain.Users) []tailcfg.UserProfile {
var profiles []tailcfg.UserProfile
for _, u := range users {
profiles = append(profiles, ToUserProfile(u))
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,
}
return profiles
login := tailcfg.Login{
ID: tailcfg.LoginID(u.ID),
LoginName: u.Name,
DisplayName: u.Name,
Domain: u.Tailnet.Name,
}
return user, login
}
func filterServices(services []tailcfg.Service) []tailcfg.Service {
result := []tailcfg.Service{}
for _, s := range services {
if s.Proto == tailcfg.TCP || s.Proto == tailcfg.UDP {
continue
}
result = append(result, s)
}
return result
}
-58
View File
@@ -1,58 +0,0 @@
package mux
import (
"crypto/tls"
"github.com/jsiebens/ionscale/internal/config"
"github.com/soheilhy/cmux"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
"net"
"net/http"
)
func Serve(grpcServer *grpc.Server, appHandler http.Handler, metricsHandler http.Handler, config *config.Config) error {
appL, err := appListener(config)
if err != nil {
return err
}
metricsL, err := metricsListener(config)
if err != nil {
return err
}
mux := cmux.New(appL)
grpcL := mux.MatchWithWriters(
cmux.HTTP2MatchHeaderFieldPrefixSendSettings("content-type", "application/grpc"),
cmux.HTTP2MatchHeaderFieldPrefixSendSettings("content-type", "application/grpc+proto"),
)
httpL := mux.Match(cmux.Any())
g := new(errgroup.Group)
g.Go(func() error { return grpcServer.Serve(grpcL) })
g.Go(func() error { return http.Serve(httpL, appHandler) })
g.Go(func() error { return http.Serve(metricsL, metricsHandler) })
g.Go(func() error { return mux.Serve() })
return g.Wait()
}
func metricsListener(config *config.Config) (net.Listener, error) {
return net.Listen("tcp", config.Metrics.ListenAddr)
}
func appListener(config *config.Config) (net.Listener, error) {
if config.Tls.Disable {
return net.Listen("tcp", config.ListenAddr)
} else {
cer, err := tls.LoadX509KeyPair(config.Tls.CertFile, config.Tls.KeyFile)
if err != nil {
return nil, err
}
tlsConfig := &tls.Config{Certificates: []tls.Certificate{cer}}
return tls.Listen("tcp", config.ListenAddr, tlsConfig)
}
}
+62 -13
View File
@@ -3,11 +3,48 @@ 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"
"runtime"
"net/http"
"strings"
"time"
)
func EchoErrorHandler(logger hclog.Logger) 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,
)
}
if strings.HasPrefix(request.RequestURI, "/a/") {
return c.Render(http.StatusInternalServerError, "error.html", nil)
}
}
return nil
}
}
}
func EchoLogger(logger hclog.Logger) echo.MiddlewareFunc {
httpLogger := logger.Named("http")
return func(next echo.HandlerFunc) echo.HandlerFunc {
@@ -36,22 +73,34 @@ func EchoLogger(logger hclog.Logger) echo.MiddlewareFunc {
}
func EchoRecover(logger hclog.Logger) echo.MiddlewareFunc {
httpLogger := logger.Named("http")
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
defer func() {
if r := recover(); r != nil {
err, ok := r.(error)
if !ok {
err = fmt.Errorf("%v", r)
apply := func() (topErr error) {
defer func() {
if r := recover(); r != nil {
err, ok := r.(error)
if !ok {
err = fmt.Errorf("%v", r)
}
topErr = err
}
stack := make([]byte, 4<<10) // 4 KB
length := runtime.Stack(stack, false)
httpLogger.Error("panic handling request", "err", err, "stack", string(stack[:length]))
c.Error(err)
}
}()
}()
return next(c)
}
return apply()
}
}
}
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
}
-54
View File
@@ -1,54 +0,0 @@
package server
import (
"github.com/grpc-ecosystem/go-grpc-middleware/v2"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery"
"github.com/grpc-ecosystem/go-grpc-prometheus"
"github.com/hashicorp/go-hclog"
"github.com/jsiebens/ionscale/internal/service"
"google.golang.org/grpc"
"tailscale.com/types/key"
)
func init() {
grpc_prometheus.EnableHandlingTimeHistogram()
}
func NewGrpcServer(logger hclog.Logger, systemAdminKey key.MachinePrivate) *grpc.Server {
return grpc.NewServer(
middleware.WithUnaryServerChain(
logging.UnaryServerInterceptor(
&grpcLogger{logger.Named("grpc")},
logging.WithDurationField(logging.DurationToDurationField),
),
grpc_prometheus.UnaryServerInterceptor,
recovery.UnaryServerInterceptor(),
service.UnaryServerTokenAuth(systemAdminKey),
),
)
}
type grpcLogger struct {
log hclog.Logger
}
func (l *grpcLogger) Log(lvl logging.Level, msg string) {
switch lvl {
case logging.ERROR:
l.log.Error(msg)
default:
l.log.Debug(msg)
}
}
func (l *grpcLogger) With(fields ...string) logging.Logger {
if len(fields) == 0 {
return l
}
vals := make([]interface{}, 0, len(fields))
for i := 0; i < len(fields); i++ {
vals = append(vals, fields[i])
}
return &grpcLogger{log: l.log.With(vals...)}
}
+16
View File
@@ -0,0 +1,16 @@
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"
apiconnect "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1/ionscalev1connect"
"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))
return apiconnect.NewIonscaleServiceHandler(handler, interceptors)
}
+210 -52
View File
@@ -1,113 +1,271 @@
package server
import (
"context"
"crypto/tls"
"fmt"
"github.com/caddyserver/certmagic"
"github.com/hashicorp/go-hclog"
"github.com/jsiebens/ionscale/internal/auth"
"github.com/jsiebens/ionscale/internal/bind"
"github.com/jsiebens/ionscale/internal/broker"
"github.com/jsiebens/ionscale/internal/config"
"github.com/jsiebens/ionscale/internal/core"
"github.com/jsiebens/ionscale/internal/database"
"github.com/jsiebens/ionscale/internal/dns"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/internal/handlers"
"github.com/jsiebens/ionscale/internal/mux"
"github.com/jsiebens/ionscale/internal/service"
"github.com/jsiebens/ionscale/internal/templates"
"github.com/jsiebens/ionscale/pkg/gen/api"
echo_prometheus "github.com/labstack/echo-contrib/prometheus"
"github.com/labstack/echo/v4"
"github.com/patrickmn/go-cache"
"github.com/labstack/echo/v4/middleware"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"golang.org/x/sync/errgroup"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"tailscale.com/types/key"
"time"
)
func Start(config *config.Config) error {
logger, err := setupLogging(config.Logging)
func Start(c *config.Config) error {
logger, err := setupLogging(c.Logging)
if err != nil {
return err
}
logger.Info("Starting ionscale server")
_, repository, err := database.OpenDB(&config.Database, logger)
repository, err := database.OpenDB(&c.Database, logger)
if err != nil {
return err
}
serverKey, err := config.ReadServerKeys()
sessionManager := core.NewPollMapSessionManager()
defaultControlKeys, err := repository.GetControlKeys(context.Background())
if err != nil {
return err
}
pendingMachineRegistrationRequests := cache.New(5*time.Minute, 10*time.Minute)
brokers := broker.NewBrokerPool()
offlineTimers := handlers.NewOfflineTimers(repository, brokers)
reaper := handlers.NewReaper(brokers, repository)
serverKey, err := c.ReadServerKeys(defaultControlKeys)
if err != nil {
return err
}
go offlineTimers.Start()
go reaper.Start()
core.StartReaper(repository, sessionManager)
createPeerHandler := func(p key.MachinePublic) http.Handler {
registrationHandlers := handlers.NewRegistrationHandlers(bind.DefaultBinder(p), config, repository, pendingMachineRegistrationRequests)
pollNetMapHandler := handlers.NewPollNetMapHandler(bind.DefaultBinder(p), brokers, repository, offlineTimers)
serverUrl, err := url.Parse(c.ServerUrl)
if err != nil {
return err
}
// 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 {
return err
}
c.HttpListenAddr = fmt.Sprintf(":%d", certmagic.HTTPPort)
c.HttpsListenAddr = fmt.Sprintf(":%d", certmagic.HTTPSPort)
}
authProvider, systemIAMPolicy, err := setupAuthProvider(c.Auth)
if err != nil {
return fmt.Errorf("error configuring OIDC provider: %v", err)
}
dnsProvider, err := dns.NewProvider(c.DNS)
if err != nil {
return err
}
p := echo_prometheus.NewPrometheus("http", nil)
metricsHandler := echo.New()
p.SetMetricsPath(metricsHandler)
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)
e := echo.New()
e.Use(EchoLogger(logger))
e.Use(EchoRecover(logger))
e.Use(EchoMetrics(p), EchoLogger(logger), EchoErrorHandler(logger), EchoRecover(logger))
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/check/:key", sshActionHandlers.CheckAuth)
return e
}
noiseHandlers := handlers.NewNoiseHandlers(serverKey.ControlKey, createPeerHandler)
registrationHandlers := handlers.NewRegistrationHandlers(bind.BoxBinder(serverKey.LegacyControlKey), config, repository, pendingMachineRegistrationRequests)
pollNetMapHandler := handlers.NewPollNetMapHandler(bind.BoxBinder(serverKey.LegacyControlKey), brokers, repository, offlineTimers)
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)
authenticationHandlers := handlers.NewAuthenticationHandlers(
config,
c,
authProvider,
systemIAMPolicy,
repository,
pendingMachineRegistrationRequests,
)
p := echo_prometheus.NewPrometheus("http", nil)
rpcService := service.NewService(c, authProvider, repository, sessionManager)
rpcPath, rpcHandler := NewRpcHandler(serverKey.SystemAdminKey, repository, logger, rpcService)
e := echo.New()
e.Renderer = templates.NewTemplates()
e.Use(EchoRecover(logger))
e.Use(EchoLogger(logger))
e.Use(p.HandlerFunc)
nonTlsAppHandler := echo.New()
nonTlsAppHandler.Use(EchoMetrics(p), EchoLogger(logger), EchoErrorHandler(logger), EchoRecover(logger))
nonTlsAppHandler.POST("/ts2021", noiseHandlers.Upgrade)
nonTlsAppHandler.Any("/*", handlers.HttpRedirectHandler(c.Tls))
m := echo.New()
p.SetMetricsPath(m)
tlsAppHandler := echo.New()
tlsAppHandler.Renderer = templates.NewTemplates()
tlsAppHandler.Pre(handlers.HttpsRedirect(c.Tls))
tlsAppHandler.Use(EchoMetrics(p), EchoLogger(logger), EchoErrorHandler(logger), EchoRecover(logger))
e.Any("/*", handlers.IndexHandler(http.StatusNotFound))
e.Any("/", handlers.IndexHandler(http.StatusOK))
e.GET("/version", handlers.Version)
e.GET("/key", handlers.KeyHandler(serverKey))
e.POST("/ts2021", noiseHandlers.Upgrade)
e.POST("/machine/:id", registrationHandlers.Register)
e.POST("/machine/:id/map", pollNetMapHandler.PollNetMap)
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)
auth := e.Group("/a")
auth.GET("/:key", authenticationHandlers.StartAuth)
auth.POST("/:key", authenticationHandlers.StartAuth)
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)
grpcService := service.NewService(repository, brokers)
grpcServer := NewGrpcServer(logger, serverKey.SystemAdminKey)
api.RegisterIonscaleServer(grpcServer, grpcService)
if config.Tls.Disable {
logger.Warn("TLS is disabled")
} else {
logger.Info("TLS is enabled", "cert", config.Tls.CertFile)
tlsL, err := tlsListener(c)
if err != nil {
return err
}
logger.Info("Server is running", "addr", config.ListenAddr, "metrics", config.Metrics.ListenAddr)
nonTlsL, err := nonTlsListener(c)
if err != nil {
return err
}
return mux.Serve(grpcServer, e, m, config)
metricsL, err := metricsListener(c)
if err != nil {
return err
}
httpL := selectListener(tlsL, nonTlsL)
http2Server := &http2.Server{}
g := new(errgroup.Group)
g.Go(func() error { return http.Serve(httpL, h2c.NewHandler(tlsAppHandler, http2Server)) })
g.Go(func() error { return http.Serve(metricsL, metricsHandler) })
if tlsL != nil {
g.Go(func() error { return http.Serve(nonTlsL, nonTlsAppHandler) })
}
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)
} 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)
} else {
logger.Warn("TLS is disabled")
logger.Info("Server is running", "http_addr", c.HttpListenAddr, "metrics_addr", c.MetricsListenAddr)
}
return g.Wait()
}
func setupAuthProvider(config config.Auth) (auth.Provider, *domain.IAMPolicy, error) {
if len(config.Provider.Issuer) == 0 {
return nil, &domain.IAMPolicy{}, nil
}
authProvider, err := auth.NewOIDCProvider(&config.Provider)
if err != nil {
return nil, nil, err
}
return authProvider, &domain.IAMPolicy{
Subs: config.SystemAdminPolicy.Subs,
Emails: config.SystemAdminPolicy.Emails,
Filters: config.SystemAdminPolicy.Filters,
}, nil
}
func metricsListener(config *config.Config) (net.Listener, error) {
return net.Listen("tcp", config.MetricsListenAddr)
}
func tlsListener(config *config.Config) (net.Listener, error) {
if config.Tls.Disable {
return nil, nil
}
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)
}
certPEMBlock, err := os.ReadFile(config.Tls.CertFile)
if err != nil {
return nil, fmt.Errorf("error reading cert file: %v", err)
}
keyPEMBlock, err := os.ReadFile(config.Tls.KeyFile)
if err != nil {
return nil, fmt.Errorf("error reading key file: %v", err)
}
cer, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
if err != nil {
return nil, fmt.Errorf("error reading cert and key file: %v", err)
}
tlsConfig := &tls.Config{Certificates: []tls.Certificate{cer}}
return tls.Listen("tcp", config.HttpsListenAddr, tlsConfig)
}
func nonTlsListener(config *config.Config) (net.Listener, error) {
return net.Listen("tcp", config.HttpListenAddr)
}
func selectListener(a net.Listener, b net.Listener) net.Listener {
if a != nil {
return a
}
return b
}
func setupLogging(config config.Logging) (hclog.Logger, error) {
+62
View File
@@ -0,0 +1,62 @@
package service
import (
"context"
"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"
)
func (s *Service) GetACLPolicy(ctx context.Context, req *connect.Request[api.GetACLPolicyRequest]) (*connect.Response[api.GetACLPolicyResponse], error) {
principal := CurrentPrincipal(ctx)
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
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
}
func (s *Service) SetACLPolicy(ctx context.Context, req *connect.Request[api.SetACLPolicyRequest]) (*connect.Response[api.SetACLPolicyResponse], error) {
principal := CurrentPrincipal(ctx)
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
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)
}
tailnet.ACLPolicy = policy
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
return nil, errors.Wrap(err, 0)
}
s.sessionManager.NotifyAll(tailnet.ID)
return connect.NewResponse(&api.SetACLPolicyResponse{}), nil
}
+74
View File
@@ -0,0 +1,74 @@
package service
import (
"context"
"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"
)
func (s *Service) Authenticate(ctx context.Context, req *connect.Request[api.AuthenticateRequest], stream *connect.ServerStream[api.AuthenticateResponse]) error {
if s.authProvider == nil {
return connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("no authentication method available, contact your ionscale administrator for more information"))
}
key := util.RandStringBytes(8)
authUrl := s.config.CreateUrl("/a/c/%s", key)
session := &domain.AuthenticationRequest{
Key: key,
CreatedAt: time.Now().UTC(),
}
if err := s.repository.SaveAuthenticationRequest(ctx, session); err != nil {
return errors.Wrap(err, 0)
}
if err := stream.Send(&api.AuthenticateResponse{AuthUrl: authUrl}); err != nil {
return errors.Wrap(err, 0)
}
notify := ctx.Done()
tick := time.NewTicker(1 * time.Second)
defer func() {
tick.Stop()
_ = s.repository.DeleteAuthenticationRequest(context.Background(), key)
}()
for {
select {
case <-tick.C:
m, err := s.repository.GetAuthenticationRequest(ctx, key)
if err != nil {
return errors.Wrap(err, 0)
}
if m == nil {
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid authentication request"))
}
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 nil
}
if len(m.Error) != 0 {
return connect.NewError(connect.CodePermissionDenied, fmt.Errorf(m.Error))
}
if err := stream.Send(&api.AuthenticateResponse{AuthUrl: authUrl}); err != nil {
return errors.Wrap(err, 0)
}
case <-notify:
return nil
}
}
}
+133 -35
View File
@@ -2,30 +2,52 @@ package service
import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
"github.com/jsiebens/ionscale/internal/domain"
"github.com/jsiebens/ionscale/pkg/gen/api"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/jsiebens/ionscale/internal/errors"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"google.golang.org/protobuf/types/known/timestamppb"
"time"
)
func (s *Service) ListAuthKeys(ctx context.Context, req *api.ListAuthKeysRequest) (*api.ListAuthKeysResponse, error) {
tailnet, err := s.repository.GetTailnet(ctx, req.TailnetId)
func (s *Service) GetAuthKey(ctx context.Context, req *connect.Request[api.GetAuthKeyRequest]) (*connect.Response[api.GetAuthKeyResponse], error) {
principal := CurrentPrincipal(ctx)
key, err := s.repository.GetAuthKey(ctx, req.Msg.AuthKeyId)
if err != nil {
return nil, err
return nil, errors.Wrap(err, 0)
}
if tailnet == nil {
return nil, status.Error(codes.NotFound, "")
if key == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("auth key not found"))
}
authKeys, err := s.repository.ListAuthKeys(ctx, req.TailnetId)
if err != nil {
return nil, err
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(key.TailnetID) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
response := api.ListAuthKeysResponse{}
var expiresAt *timestamppb.Timestamp
if key.ExpiresAt != nil {
expiresAt = timestamppb.New(*key.ExpiresAt)
}
return connect.NewResponse(&api.GetAuthKeyResponse{AuthKey: &api.AuthKey{
Id: key.ID,
Key: key.Key,
Ephemeral: key.Ephemeral,
Tags: key.Tags,
CreatedAt: timestamppb.New(key.CreatedAt),
ExpiresAt: expiresAt,
Tailnet: &api.Ref{
Id: key.Tailnet.ID,
Name: key.Tailnet.Name,
},
}}), nil
}
func mapAuthKeysToApi(authKeys []domain.AuthKey) []*api.AuthKey {
var result []*api.AuthKey
for _, key := range authKeys {
var expiresAt *timestamppb.Timestamp
@@ -33,57 +55,117 @@ func (s *Service) ListAuthKeys(ctx context.Context, req *api.ListAuthKeysRequest
expiresAt = timestamppb.New(*key.ExpiresAt)
}
response.AuthKeys = append(response.AuthKeys, &api.AuthKey{
result = append(result, &api.AuthKey{
Id: key.ID,
Key: key.Key,
Ephemeral: key.Ephemeral,
Tags: key.Tags,
CreatedAt: timestamppb.New(key.CreatedAt),
ExpiresAt: expiresAt,
Tailnet: &api.Ref{
Id: tailnet.ID,
Name: tailnet.Name,
Id: key.Tailnet.ID,
Name: key.Tailnet.Name,
},
})
}
return &response, nil
return result
}
func (s *Service) CreateAuthKey(ctx context.Context, req *api.CreateAuthKeyRequest) (*api.CreateAuthKeyResponse, error) {
if len(req.Tags) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "at least one tag is required when creating an auth key")
func (s *Service) ListAuthKeys(ctx context.Context, req *connect.Request[api.ListAuthKeysRequest]) (*connect.Response[api.ListAuthKeysResponse], error) {
principal := CurrentPrincipal(ctx)
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
tailnet, err := s.repository.GetTailnet(ctx, req.TailnetId)
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, err
return nil, errors.Wrap(err, 0)
}
if tailnet == nil {
return nil, status.Error(codes.NotFound, "")
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet not found"))
}
response := api.ListAuthKeysResponse{}
if principal.IsSystemAdmin() {
authKeys, err := s.repository.ListAuthKeys(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
response.AuthKeys = mapAuthKeysToApi(authKeys)
return connect.NewResponse(&response), nil
}
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)
}
response.AuthKeys = mapAuthKeysToApi(authKeys)
return connect.NewResponse(&response), nil
}
return connect.NewResponse(&response), nil
}
func (s *Service) CreateAuthKey(ctx context.Context, req *connect.Request[api.CreateAuthKeyRequest]) (*connect.Response[api.CreateAuthKeyResponse], error) {
principal := CurrentPrincipal(ctx)
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
if principal.User == nil && len(req.Msg.Tags) == 0 {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("at least one tag is required when creating an auth key"))
}
if err := domain.CheckTags(req.Msg.Tags); err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
if tailnet == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet not found"))
}
if !principal.IsSystemAdmin() {
if err := tailnet.ACLPolicy.CheckTagOwners(req.Msg.Tags, principal.User); err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
}
var expiresAt *time.Time
var expiresAtPb *timestamppb.Timestamp
if req.Expiry != nil {
duration := req.Expiry.AsDuration()
if req.Msg.Expiry != nil {
duration := req.Msg.Expiry.AsDuration()
e := time.Now().UTC().Add(duration)
expiresAt = &e
expiresAtPb = timestamppb.New(*expiresAt)
}
user, _, err := s.repository.GetOrCreateServiceUser(ctx, tailnet)
if err != nil {
return nil, err
var user = principal.User
if user == nil {
u, _, err := s.repository.GetOrCreateServiceUser(ctx, tailnet)
if err != nil {
return nil, errors.Wrap(err, 0)
}
user = u
}
tags := domain.SanitizeTags(req.Tags)
tags := domain.SanitizeTags(req.Msg.Tags)
v, authKey := domain.CreateAuthKey(tailnet, user, req.Ephemeral, tags, expiresAt)
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, err
return nil, errors.Wrap(err, 0)
}
response := api.CreateAuthKeyResponse{
@@ -92,6 +174,7 @@ func (s *Service) CreateAuthKey(ctx context.Context, req *api.CreateAuthKeyReque
Id: authKey.ID,
Key: authKey.Key,
Ephemeral: authKey.Ephemeral,
Tags: authKey.Tags,
CreatedAt: timestamppb.New(authKey.CreatedAt),
ExpiresAt: expiresAtPb,
Tailnet: &api.Ref{
@@ -100,12 +183,27 @@ func (s *Service) CreateAuthKey(ctx context.Context, req *api.CreateAuthKeyReque
},
}}
return &response, nil
return connect.NewResponse(&response), nil
}
func (s *Service) DeleteAuthKey(ctx context.Context, req *api.DeleteAuthKeyRequest) (*api.DeleteAuthKeyResponse, error) {
if _, err := s.repository.DeleteAuthKey(ctx, req.AuthKeyId); err != nil {
return nil, err
func (s *Service) DeleteAuthKey(ctx context.Context, req *connect.Request[api.DeleteAuthKeyRequest]) (*connect.Response[api.DeleteAuthKeyResponse], error) {
principal := CurrentPrincipal(ctx)
key, err := s.repository.GetAuthKey(ctx, req.Msg.AuthKeyId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
return &api.DeleteAuthKeyResponse{}, nil
if key == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("auth key not found"))
}
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(key.UserID) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
if _, err := s.repository.DeleteAuthKey(ctx, req.Msg.AuthKeyId); err != nil {
return nil, errors.Wrap(err, 0)
}
return connect.NewResponse(&api.DeleteAuthKeyResponse{}), nil
}
+88
View File
@@ -0,0 +1,88 @@
package service
import (
"context"
"encoding/json"
"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) {
principal := CurrentPrincipal(ctx)
if !principal.IsSystemAdmin() {
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)
}
raw, err := json.Marshal(dm.DERPMap)
if err != nil {
return nil, errors.Wrap(err, 0)
}
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
}
+97
View File
@@ -0,0 +1,97 @@
package service
import (
"context"
"fmt"
"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"
)
func (s *Service) GetDNSConfig(ctx context.Context, req *connect.Request[api.GetDNSConfigRequest]) (*connect.Response[api.GetDNSConfigResponse], error) {
principal := CurrentPrincipal(ctx)
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
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),
},
}
return connect.NewResponse(resp), nil
}
func (s *Service) SetDNSConfig(ctx context.Context, req *connect.Request[api.SetDNSConfigRequest]) (*connect.Response[api.SetDNSConfigResponse], error) {
principal := CurrentPrincipal(ctx)
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
dnsConfig := req.Msg.Config
if dnsConfig.HttpsCerts && !dnsConfig.MagicDns {
return nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("MagicDNS must be enabled when enabling HTTPS Certs"))
}
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
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),
}
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
return nil, errors.Wrap(err, 0)
}
s.sessionManager.NotifyAll(tailnet.ID)
resp := &api.SetDNSConfigResponse{Config: dnsConfig}
return connect.NewResponse(resp), nil
}
func domainRoutesToApiRoutes(routes map[string][]string) map[string]*api.Routes {
var result = map[string]*api.Routes{}
for k, v := range routes {
result[k] = &api.Routes{Routes: v}
}
return result
}
func apiRoutesToDomainRoutes(routes map[string]*api.Routes) map[string][]string {
var result = map[string][]string{}
for k, v := range routes {
result[k] = v.Routes
}
return result
}
+78
View File
@@ -0,0 +1,78 @@
package service
import (
"context"
"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"
)
func (s *Service) GetIAMPolicy(ctx context.Context, req *connect.Request[api.GetIAMPolicyRequest]) (*connect.Response[api.GetIAMPolicyResponse], error) {
principal := CurrentPrincipal(ctx)
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
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
}
func (s *Service) SetIAMPolicy(ctx context.Context, req *connect.Request[api.SetIAMPolicyRequest]) (*connect.Response[api.SetIAMPolicyResponse], error) {
principal := CurrentPrincipal(ctx)
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
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),
}
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
return nil, errors.Wrap(err, 0)
}
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
}
+131
View File
@@ -0,0 +1,131 @@
package service
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"
"strings"
)
var (
errInvalidToken = connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("invalid token"))
)
const (
principalKey = "principalKay"
)
func CurrentPrincipal(ctx context.Context) domain.Principal {
p := ctx.Value(principalKey)
if p == nil {
return domain.Principal{SystemRole: domain.SystemRoleNone, UserRole: domain.UserRoleNone}
}
return p.(domain.Principal)
}
func AuthenticationInterceptor(systemAdminKey *key.ServerPrivate, repository domain.Repository) connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
name := req.Spec().Procedure
if strings.HasSuffix(name, "/GetVersion") {
return next(ctx, req)
}
authorizationHeader := req.Header().Get("Authorization")
bearerToken := strings.TrimPrefix(authorizationHeader, "Bearer ")
if principal := exchangeToken(ctx, systemAdminKey, repository, bearerToken); principal != nil {
return next(context.WithValue(ctx, principalKey, *principal), req)
}
return nil, errInvalidToken
}
}
}
func exchangeToken(ctx context.Context, systemAdminKey *key.ServerPrivate, repository domain.Repository, value string) *domain.Principal {
if len(value) == 0 {
return nil
}
if systemAdminKey != nil && token.IsSystemAdminToken(value) {
_, err := token.ParseSystemAdminToken(*systemAdminKey, value)
if err == nil {
return &domain.Principal{SystemRole: domain.SystemRoleAdmin}
}
}
apiKey, err := repository.LoadApiKey(ctx, value)
if err == nil && apiKey != nil {
user := apiKey.User
tailnet := apiKey.Tailnet
role := tailnet.IAMPolicy.GetRole(user)
return &domain.Principal{User: &apiKey.User, SystemRole: domain.SystemRoleNone, UserRole: role}
}
systemApiKey, err := repository.LoadSystemApiKey(ctx, value)
if err == nil && systemApiKey != nil {
return &domain.Principal{SystemRole: domain.SystemRoleAdmin}
}
return nil
}
func NewErrorInterceptor(logger hclog.Logger) *ErrorInterceptor {
return &ErrorInterceptor{
logger: logger,
}
}
type ErrorInterceptor struct {
logger hclog.Logger
}
func (e *ErrorInterceptor) handleError(err error) error {
if err == nil {
return err
}
switch t := 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)
return response, e.handleError(err)
}
}
func (e *ErrorInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn {
return next(ctx, spec)
}
}
func (e *ErrorInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
err := next(ctx, conn)
return e.handleError(err)
}
}
+411 -44
View File
@@ -3,74 +3,441 @@ package service
import (
"context"
"fmt"
"github.com/jsiebens/ionscale/pkg/gen/api"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"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"
"net/netip"
"time"
)
func (s *Service) ListMachines(ctx context.Context, req *api.ListMachinesRequest) (*api.ListMachinesResponse, error) {
tailnet, err := s.repository.GetTailnet(ctx, req.TailnetId)
func (s *Service) machineToApi(m *domain.Machine) *api.Machine {
var lastSeen *timestamppb.Timestamp
var name = m.Name
if m.NameIdx != 0 {
name = fmt.Sprintf("%s-%d", m.Name, m.NameIdx)
}
online := s.sessionManager.HasSession(m.TailnetID, m.ID)
if m.LastSeen != nil {
lastSeen = timestamppb.New(*m.LastSeen)
}
return &api.Machine{
Id: m.ID,
Name: name,
Ipv4: m.IPv4.String(),
Ipv6: m.IPv6.String(),
Ephemeral: m.Ephemeral,
Tags: m.Tags,
LastSeen: lastSeen,
CreatedAt: timestamppb.New(m.CreatedAt),
ExpiresAt: timestamppb.New(m.ExpiresAt),
KeyExpiryDisabled: m.KeyExpiryDisabled,
Connected: online,
Os: m.HostInfo.OS,
ClientVersion: m.HostInfo.IPNVersion,
Tailnet: &api.Ref{
Id: m.Tailnet.ID,
Name: m.Tailnet.Name,
},
User: &api.Ref{
Id: m.User.ID,
Name: m.User.Name,
},
ClientConnectivity: &api.ClientConnectivity{
Endpoints: m.Endpoints,
},
AdvertisedRoutes: m.AdvertisedPrefixes(),
EnabledRoutes: m.AllowedPrefixes(),
AdvertisedExitNode: m.IsAdvertisedExitNode(),
EnabledExitNode: m.IsAllowedExitNode(),
Authorized: m.Authorized,
}
}
func (s *Service) ListMachines(ctx context.Context, req *connect.Request[api.ListMachinesRequest]) (*connect.Response[api.ListMachinesResponse], error) {
principal := CurrentPrincipal(ctx)
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
if err != nil {
return nil, err
return nil, errors.Wrap(err, 0)
}
if tailnet == nil {
return nil, status.Error(codes.NotFound, "tailnet does not exist")
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet not found"))
}
machines, err := s.repository.ListMachineByTailnet(ctx, tailnet.ID)
if err != nil {
return nil, err
return nil, errors.Wrap(err, 0)
}
response := &api.ListMachinesResponse{}
for _, m := range machines {
var name = m.Name
if m.NameIdx != 0 {
name = fmt.Sprintf("%s-%d", m.Name, m.NameIdx)
}
online := s.brokers(m.TailnetID).IsConnected(m.ID)
var lastSeen *timestamppb.Timestamp
if m.LastSeen != nil {
lastSeen = timestamppb.New(*m.LastSeen)
}
response.Machines = append(response.Machines, &api.Machine{
Id: m.ID,
Name: name,
Ipv4: m.IPv4,
Ipv6: m.IPv6,
Ephemeral: m.Ephemeral,
LastSeen: lastSeen,
Connected: online,
Tailnet: &api.Ref{
Id: m.Tailnet.ID,
Name: m.Tailnet.Name,
},
User: &api.Ref{
Id: m.User.ID,
Name: m.User.Name,
},
})
response.Machines = append(response.Machines, s.machineToApi(&m))
}
return response, nil
return connect.NewResponse(response), nil
}
func (s *Service) DeleteMachine(ctx context.Context, req *api.DeleteMachineRequest) (*api.DeleteMachineResponse, error) {
m, err := s.repository.GetMachine(ctx, req.MachineId)
func (s *Service) GetMachine(ctx context.Context, req *connect.Request[api.GetMachineRequest]) (*connect.Response[api.GetMachineResponse], error) {
principal := CurrentPrincipal(ctx)
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
if err != nil {
return nil, err
return nil, errors.Wrap(err, 0)
}
if m == nil {
return nil, status.Error(codes.NotFound, "machine does not exist")
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("machine not found"))
}
if _, err := s.repository.DeleteMachine(ctx, req.MachineId); err != nil {
return nil, err
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
s.brokers(m.TailnetID).SignalPeersRemoved([]uint64{m.ID})
return &api.DeleteMachineResponse{}, nil
return connect.NewResponse(&api.GetMachineResponse{Machine: s.machineToApi(m)}), nil
}
func (s *Service) DeleteMachine(ctx context.Context, req *connect.Request[api.DeleteMachineRequest]) (*connect.Response[api.DeleteMachineResponse], error) {
principal := CurrentPrincipal(ctx)
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
if m == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("machine not found"))
}
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
if _, err := s.repository.DeleteMachine(ctx, req.Msg.MachineId); err != nil {
return nil, errors.Wrap(err, 0)
}
s.sessionManager.NotifyAll(m.TailnetID)
return connect.NewResponse(&api.DeleteMachineResponse{}), nil
}
func (s *Service) ExpireMachine(ctx context.Context, req *connect.Request[api.ExpireMachineRequest]) (*connect.Response[api.ExpireMachineResponse], error) {
principal := CurrentPrincipal(ctx)
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
if m == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("machine not found"))
}
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
timestamp := time.Unix(123, 0)
m.ExpiresAt = timestamp
m.KeyExpiryDisabled = false
if err := s.repository.SaveMachine(ctx, m); err != nil {
return nil, errors.Wrap(err, 0)
}
s.sessionManager.NotifyAll(m.TailnetID)
return connect.NewResponse(&api.ExpireMachineResponse{}), nil
}
func (s *Service) AuthorizeMachine(ctx context.Context, req *connect.Request[api.AuthorizeMachineRequest]) (*connect.Response[api.AuthorizeMachineResponse], error) {
principal := CurrentPrincipal(ctx)
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
if m == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("machine not found"))
}
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
if !m.Authorized {
m.Authorized = true
if err := s.repository.SaveMachine(ctx, m); err != nil {
return nil, errors.Wrap(err, 0)
}
}
s.sessionManager.NotifyAll(m.TailnetID)
return connect.NewResponse(&api.AuthorizeMachineResponse{}), nil
}
func (s *Service) GetMachineRoutes(ctx context.Context, req *connect.Request[api.GetMachineRoutesRequest]) (*connect.Response[api.GetMachineRoutesResponse], error) {
principal := CurrentPrincipal(ctx)
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
if m == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("machine not found"))
}
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
response := api.GetMachineRoutesResponse{
MachineId: m.ID,
Routes: &api.MachineRoutes{
AdvertisedRoutes: m.AdvertisedPrefixes(),
EnabledRoutes: m.AllowedPrefixes(),
AdvertisedExitNode: m.IsAdvertisedExitNode(),
EnabledExitNode: m.IsAllowedExitNode(),
},
}
return connect.NewResponse(&response), nil
}
func (s *Service) EnableMachineRoutes(ctx context.Context, req *connect.Request[api.EnableMachineRoutesRequest]) (*connect.Response[api.EnableMachineRoutesResponse], error) {
principal := CurrentPrincipal(ctx)
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
if m == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("machine not found"))
}
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
var allowIPs = domain.NewAllowIPsSet(m.AllowIPs)
var autoAllowIPs = domain.NewAllowIPsSet(m.AutoAllowIPs)
if req.Msg.Replace {
allowIPs = domain.NewAllowIPsSet([]netip.Prefix{})
autoAllowIPs = domain.NewAllowIPsSet([]netip.Prefix{})
}
for _, r := range req.Msg.Routes {
prefix, err := netip.ParsePrefix(r)
if err != nil {
return nil, errors.Wrap(err, 0)
}
allowIPs.Add(prefix)
}
m.AllowIPs = allowIPs.Items()
m.AutoAllowIPs = autoAllowIPs.Items()
if err := s.repository.SaveMachine(ctx, m); err != nil {
return nil, errors.Wrap(err, 0)
}
s.sessionManager.NotifyAll(m.TailnetID)
response := api.EnableMachineRoutesResponse{
MachineId: m.ID,
Routes: &api.MachineRoutes{
AdvertisedRoutes: m.AdvertisedPrefixes(),
EnabledRoutes: m.AllowedPrefixes(),
AdvertisedExitNode: m.IsAdvertisedExitNode(),
EnabledExitNode: m.IsAllowedExitNode(),
},
}
return connect.NewResponse(&response), nil
}
func (s *Service) DisableMachineRoutes(ctx context.Context, req *connect.Request[api.DisableMachineRoutesRequest]) (*connect.Response[api.DisableMachineRoutesResponse], error) {
principal := CurrentPrincipal(ctx)
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
if m == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("machine not found"))
}
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
allowIPs := domain.NewAllowIPsSet(m.AllowIPs)
autoAllowIPs := domain.NewAllowIPsSet(m.AutoAllowIPs)
for _, r := range req.Msg.Routes {
prefix, err := netip.ParsePrefix(r)
if err != nil {
return nil, errors.Wrap(err, 0)
}
allowIPs.Remove(prefix)
autoAllowIPs.Remove(prefix)
}
m.AllowIPs = allowIPs.Items()
m.AutoAllowIPs = autoAllowIPs.Items()
if err := s.repository.SaveMachine(ctx, m); err != nil {
return nil, errors.Wrap(err, 0)
}
s.sessionManager.NotifyAll(m.TailnetID)
response := api.DisableMachineRoutesResponse{
MachineId: m.ID,
Routes: &api.MachineRoutes{
AdvertisedRoutes: m.AdvertisedPrefixes(),
EnabledRoutes: m.AllowedPrefixes(),
AdvertisedExitNode: m.IsAdvertisedExitNode(),
EnabledExitNode: m.IsAllowedExitNode(),
},
}
return connect.NewResponse(&response), nil
}
func (s *Service) EnableExitNode(ctx context.Context, req *connect.Request[api.EnableExitNodeRequest]) (*connect.Response[api.EnableExitNodeResponse], error) {
principal := CurrentPrincipal(ctx)
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
if m == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("machine not found"))
}
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
if !m.IsAdvertisedExitNode() {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("machine is not a valid exit node"))
}
prefix4 := netip.MustParsePrefix("0.0.0.0/0")
prefix6 := netip.MustParsePrefix("::/0")
allowIPs := domain.NewAllowIPsSet(m.AllowIPs)
allowIPs.Add(prefix4, prefix6)
m.AllowIPs = allowIPs.Items()
if err := s.repository.SaveMachine(ctx, m); err != nil {
return nil, errors.Wrap(err, 0)
}
s.sessionManager.NotifyAll(m.TailnetID)
response := api.EnableExitNodeResponse{
MachineId: m.ID,
Routes: &api.MachineRoutes{
AdvertisedRoutes: m.AdvertisedPrefixes(),
EnabledRoutes: m.AllowedPrefixes(),
AdvertisedExitNode: m.IsAdvertisedExitNode(),
EnabledExitNode: m.IsAllowedExitNode(),
},
}
return connect.NewResponse(&response), nil
}
func (s *Service) DisableExitNode(ctx context.Context, req *connect.Request[api.DisableExitNodeRequest]) (*connect.Response[api.DisableExitNodeResponse], error) {
principal := CurrentPrincipal(ctx)
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
if m == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("machine not found"))
}
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
if !m.IsAdvertisedExitNode() {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("machine is not a valid exit node"))
}
prefix4 := netip.MustParsePrefix("0.0.0.0/0")
prefix6 := netip.MustParsePrefix("::/0")
allowIPs := domain.NewAllowIPsSet(m.AllowIPs)
allowIPs.Remove(prefix4, prefix6)
autoAllowIPs := domain.NewAllowIPsSet(m.AutoAllowIPs)
autoAllowIPs.Remove(prefix4, prefix6)
m.AllowIPs = allowIPs.Items()
m.AutoAllowIPs = autoAllowIPs.Items()
if err := s.repository.SaveMachine(ctx, m); err != nil {
return nil, errors.Wrap(err, 0)
}
s.sessionManager.NotifyAll(m.TailnetID)
response := api.DisableExitNodeResponse{
MachineId: m.ID,
Routes: &api.MachineRoutes{
AdvertisedRoutes: m.AdvertisedPrefixes(),
EnabledRoutes: m.AllowedPrefixes(),
AdvertisedExitNode: m.IsAdvertisedExitNode(),
EnabledExitNode: m.IsAllowedExitNode(),
},
}
return connect.NewResponse(&response), nil
}
func (s *Service) SetMachineKeyExpiry(ctx context.Context, req *connect.Request[api.SetMachineKeyExpiryRequest]) (*connect.Response[api.SetMachineKeyExpiryResponse], error) {
principal := CurrentPrincipal(ctx)
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
if err != nil {
return nil, errors.Wrap(err, 0)
}
if m == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("machine not found"))
}
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
m.KeyExpiryDisabled = req.Msg.Disabled
if err := s.repository.SaveMachine(ctx, m); err != nil {
return nil, errors.Wrap(err, 0)
}
s.sessionManager.NotifyAll(m.TailnetID)
return connect.NewResponse(&api.SetMachineKeyExpiryResponse{}), nil
}

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