You've already forked ionscale
mirror of
https://github.com/jsiebens/ionscale.git
synced 2026-04-05 20:42:58 +01:00
Compare commits
150 Commits
v0.0.0-dev
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 11af121126 | |||
| dfb91d2419 | |||
| daf577a0ee | |||
| a364188761 | |||
| ea4fe22e35 | |||
| ddc65d2df9 | |||
| c70a4cfe6a | |||
| 5bf919da12 | |||
| 6d4a7b7014 | |||
| bc1f188816 | |||
| 9522e3531e | |||
| 1e3541e7c8 | |||
| c3e1344199 | |||
| 70b9373df3 | |||
| 58de86a978 | |||
| 2e57338b54 | |||
| 7cadcc9085 | |||
| 22cfe60c7d | |||
| 45572397ea | |||
| e5a3d3c589 | |||
| 2a5fe7f136 | |||
| 7ee4b27688 | |||
| 69f7c22307 | |||
| 4e5f89ab7e | |||
| c1ffe03e81 | |||
| 7ad91c4c20 | |||
| fb04248db4 | |||
| d84bad12d0 | |||
| cadf938e2a | |||
| 980ae6dd85 | |||
| 6e3e22bc72 | |||
| 0051eec355 | |||
| 617575803c | |||
| 8c6ea9041b | |||
| c6ebeb36bc | |||
| d87c7252c2 | |||
| bfcf0c7925 | |||
| aea3d2d6a9 | |||
| 9781e75833 | |||
| 47b15d31f0 | |||
| ec353f7add | |||
| 92ca75b7f4 | |||
| 1702cf135e | |||
| b65119bbba | |||
| b265fc42c7 | |||
| 69dd1f6b95 | |||
| ebf0016096 | |||
| 3aa2d68ce2 | |||
| 3d03f49138 | |||
| c4783f8165 | |||
| 3b9ce04ec8 | |||
| f71ca49693 | |||
| 61d78fe121 | |||
| 5b51e29140 | |||
| e5ed4713d8 | |||
| 9281deb549 | |||
| 88509c826d | |||
| 405110867a | |||
| 82c814aa2a | |||
| 5a524d7357 | |||
| 0f0829ccba | |||
| 4c9ea463db | |||
| 284ec18339 | |||
| 5a77d2b35b | |||
| c193a4bf71 | |||
| 550febc5ba | |||
| f0d71c8a66 | |||
| 3c50d4869d | |||
| e8fe0e2467 | |||
| 633f29003c | |||
| 145ae6ab1d | |||
| b60e332cbd | |||
| f38939415d | |||
| 49e5c7999f | |||
| 82a28e32c0 | |||
| 7976e7aa83 | |||
| 404b667aaf | |||
| 6700d0db01 | |||
| 25ee5a21a6 | |||
| d735974406 | |||
| 41827dcdcd | |||
| cd1854f510 | |||
| 6a6049b76b | |||
| 50d52ae481 | |||
| 402f98b688 | |||
| 4234c5eed9 | |||
| 3568764ec1 | |||
| df02644437 | |||
| 7db10b563d | |||
| 496fd5f47c | |||
| 200b523ae0 | |||
| f225f427ac | |||
| 70e84be8f4 | |||
| 409dd3aa5f | |||
| 0d5ffa9c8b | |||
| 0756de5bfb | |||
| 32cb12e286 | |||
| f6961cf2f7 | |||
| ba379e1b65 | |||
| 12eb258e1e | |||
| 32c396a972 | |||
| d0e69cc2bf | |||
| 5e132392b3 | |||
| 58e1f38231 | |||
| d5f71224f6 | |||
| 090e5c3c88 | |||
| 8e8646b757 | |||
| a94e0ce9b8 | |||
| eefa150738 | |||
| bbe9d16294 | |||
| 5fdde45fdd | |||
| 1715eb681d | |||
| 9d29644941 | |||
| da71a43990 | |||
| 687fcd16d1 | |||
| b9b42d8342 | |||
| 1654680cab | |||
| 9df514036e | |||
| 85656c19a7 | |||
| 2b5439bd60 | |||
| 198b6795b1 | |||
| 84a57ea409 | |||
| 37e94ac915 | |||
| 00554118f6 | |||
| 0e64765b13 | |||
| 03fd19958a | |||
| e8dc2ee34f | |||
| 0e3ca9f419 | |||
| 482194a506 | |||
| 9a5be02dbb | |||
| c04a5e26d1 | |||
| 557c43192a | |||
| 68223f9c8d | |||
| fadaca6ec7 | |||
| 6ae82edf70 | |||
| 0a9aab79e0 | |||
| a804aea79b | |||
| b1974d7f83 | |||
| 6365869da2 | |||
| f5a2719313 | |||
| 3d629a0f93 | |||
| 9769f40db5 | |||
| c74a082660 | |||
| 2d4f614592 | |||
| 3aceacbc8d | |||
| 52aa221cd0 | |||
| e5c7a118a8 | |||
| 22cccceca9 | |||
| ee262b1a35 | |||
| c55a956507 |
@@ -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
|
||||
@@ -0,0 +1,26 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
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
|
||||
@@ -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.5.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 }}
|
||||
@@ -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.5.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 }}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -1,2 +1,8 @@
|
||||
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
|
||||
@@ -1 +1,4 @@
|
||||
# ionscale
|
||||
|
||||
> **Note**:
|
||||
> ionscale is currently alpha quality, actively being developed and so subject to changes
|
||||
+3
-6
@@ -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
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/muesli/coral"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cmd := rootCommand()
|
||||
if err := cmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func rootCommand() *coral.Command {
|
||||
command := &coral.Command{
|
||||
Use: "pg-ionscale-events",
|
||||
}
|
||||
|
||||
var url string
|
||||
command.Flags().StringVar(&url, "url", "", "")
|
||||
_ = command.MarkFlagRequired("url")
|
||||
|
||||
command.RunE = func(cmd *coral.Command, args []string) error {
|
||||
_, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reportProblem := func(ev pq.ListenerEventType, err error) {
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
minReconn := 10 * time.Second
|
||||
maxReconn := time.Minute
|
||||
listener := pq.NewListener(url, minReconn, maxReconn, reportProblem)
|
||||
err = listener.Listen("ionscale_events")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("listening for events ...")
|
||||
fmt.Println("")
|
||||
for {
|
||||
select {
|
||||
case n, _ := <-listener.Notify:
|
||||
fmt.Println(n.Extra)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
@@ -1,78 +1,150 @@
|
||||
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.4.6
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.0.2
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2
|
||||
github.com/google/uuid v1.3.0
|
||||
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/klauspost/compress v1.15.9
|
||||
github.com/labstack/echo-contrib v0.13.0
|
||||
github.com/labstack/echo/v4 v4.9.0
|
||||
github.com/lib/pq v1.10.6
|
||||
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.0.0-20221005025214-4161e89ecf1b
|
||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af
|
||||
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.3.9
|
||||
gorm.io/gorm v1.23.8
|
||||
inet.af/netaddr v0.0.0-20220811202034-502d2d690317
|
||||
tailscale.com v1.30.2
|
||||
)
|
||||
|
||||
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-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.18.1 // 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/uuid v1.3.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.0 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/google/go-querystring 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/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/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/prometheus/common v0.37.0 // indirect
|
||||
github.com/prometheus/procfs v0.8.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // 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
|
||||
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.0.0-20221006211917-84dc82d7e875 // indirect
|
||||
golang.org/x/text v0.3.8-0.20211105212822-18b340fc7af2 // 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.4.10 // indirect
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // 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.18.0 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.3.0 // indirect
|
||||
modernc.org/sqlite v1.18.1 // indirect
|
||||
nhooyr.io/websocket v1.8.7 // indirect
|
||||
)
|
||||
|
||||
+28
-12
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -1,102 +1 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package broker
|
||||
|
||||
type Signal struct {
|
||||
PeerUpdated *uint64
|
||||
PeersRemoved []uint64
|
||||
ACLUpdated bool
|
||||
DNSUpdated bool
|
||||
}
|
||||
|
||||
type Listener chan *Signal
|
||||
|
||||
type Pubsub interface {
|
||||
Subscribe(tailnet uint64, listener Listener) (cancel func(), err error)
|
||||
Publish(tailnet uint64, message *Signal) error
|
||||
Close() error
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type memoryPubsub struct {
|
||||
mut sync.RWMutex
|
||||
listeners map[uint64]map[uuid.UUID]Listener
|
||||
}
|
||||
|
||||
func (m *memoryPubsub) Subscribe(tailnet uint64, listener Listener) (cancel func(), err error) {
|
||||
m.mut.Lock()
|
||||
defer m.mut.Unlock()
|
||||
|
||||
var listeners map[uuid.UUID]Listener
|
||||
var ok bool
|
||||
if listeners, ok = m.listeners[tailnet]; !ok {
|
||||
listeners = map[uuid.UUID]Listener{}
|
||||
m.listeners[tailnet] = listeners
|
||||
}
|
||||
var id uuid.UUID
|
||||
for {
|
||||
id = uuid.New()
|
||||
if _, ok = listeners[id]; !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
listeners[id] = listener
|
||||
return func() {
|
||||
m.mut.Lock()
|
||||
defer m.mut.Unlock()
|
||||
listeners := m.listeners[tailnet]
|
||||
delete(listeners, id)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *memoryPubsub) Publish(tailnet uint64, message *Signal) error {
|
||||
m.mut.RLock()
|
||||
defer m.mut.RUnlock()
|
||||
listeners, ok := m.listeners[tailnet]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, listener := range listeners {
|
||||
listener <- message
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*memoryPubsub) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewPubsubInMemory() Pubsub {
|
||||
return &memoryPubsub{
|
||||
listeners: make(map[uint64]map[uuid.UUID]Listener),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/lib/pq"
|
||||
"time"
|
||||
)
|
||||
|
||||
type pgPubsub struct {
|
||||
pgListener *pq.Listener
|
||||
db *sql.DB
|
||||
target Pubsub
|
||||
}
|
||||
|
||||
func NewPubsub(ctx context.Context, database *sql.DB, connectURL string) (Pubsub, error) {
|
||||
errCh := make(chan error)
|
||||
listener := pq.NewListener(connectURL, time.Second, time.Minute, func(event pq.ListenerEventType, err error) {
|
||||
select {
|
||||
case <-errCh:
|
||||
return
|
||||
default:
|
||||
errCh <- err
|
||||
close(errCh)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pq listener: %w", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
if err := listener.Listen("ionscale_events"); err != nil {
|
||||
return nil, fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
|
||||
pubsub := &pgPubsub{
|
||||
db: database,
|
||||
pgListener: listener,
|
||||
target: NewPubsubInMemory(),
|
||||
}
|
||||
go pubsub.listen(ctx)
|
||||
|
||||
return pubsub, nil
|
||||
}
|
||||
|
||||
func (p *pgPubsub) Close() error {
|
||||
return p.pgListener.Close()
|
||||
}
|
||||
|
||||
func (p *pgPubsub) Subscribe(tailnet uint64, listener Listener) (cancel func(), err error) {
|
||||
return p.target.Subscribe(tailnet, listener)
|
||||
}
|
||||
|
||||
func (p *pgPubsub) Publish(tailnet uint64, message *Signal) error {
|
||||
event := &pgEvent{
|
||||
TailnetID: tailnet,
|
||||
Signal: message,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = p.db.ExecContext(context.Background(), `select pg_notify(`+pq.QuoteLiteral("ionscale_events")+`, $1)`, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("exec pg_notify: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pgPubsub) listen(ctx context.Context) {
|
||||
var (
|
||||
notif *pq.Notification
|
||||
ok bool
|
||||
)
|
||||
defer p.pgListener.Close()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case notif, ok = <-p.pgListener.Notify:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
// A nil notification can be dispatched on reconnect.
|
||||
if notif == nil {
|
||||
continue
|
||||
}
|
||||
p.listenReceive(notif)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pgPubsub) listenReceive(notif *pq.Notification) {
|
||||
extra := []byte(notif.Extra)
|
||||
event := &pgEvent{}
|
||||
|
||||
if err := json.Unmarshal(extra, event); err == nil {
|
||||
p.target.Publish(event.TailnetID, event.Signal)
|
||||
} else {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
type pgEvent struct {
|
||||
TailnetID uint64
|
||||
Signal *Signal
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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"
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
func getACLConfigCommand() *coral.Command {
|
||||
command := &coral.Command{
|
||||
Use: "get-acl-policy",
|
||||
Short: "Get the ACL policy",
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
var asJson 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().BoolVar(&asJson, "json", false, "When enabled, render output as json otherwise yaml")
|
||||
|
||||
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 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
|
||||
}
|
||||
@@ -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.AuthenticationRequest{}
|
||||
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
|
||||
}
|
||||
+29
-23
@@ -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,6 +30,7 @@ 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,
|
||||
}
|
||||
|
||||
@@ -37,18 +42,18 @@ func createAuthkeysCommand() *coral.Command {
|
||||
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.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 {
|
||||
@@ -71,7 +76,7 @@ func createAuthkeysCommand() *coral.Command {
|
||||
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 +86,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 +98,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 +129,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 +138,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 +154,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 +169,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 +183,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, ","))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
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
|
||||
|
||||
var allNameservers = config.Nameservers
|
||||
|
||||
for i, j := range config.Routes {
|
||||
for _, n := range j.Routes {
|
||||
allNameservers = append(allNameservers, fmt.Sprintf("%s:%s", i, n))
|
||||
}
|
||||
}
|
||||
|
||||
w := new(tabwriter.Writer)
|
||||
w.Init(os.Stdout, 8, 8, 0, '\t', 0)
|
||||
defer w.Flush()
|
||||
|
||||
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(&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
|
||||
}
|
||||
|
||||
func enableHttpsCommand() *coral.Command {
|
||||
command := &coral.Command{
|
||||
Use: "enable-https",
|
||||
Short: "Enable HTTPS certificates",
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
var tailnetID uint64
|
||||
var tailnetName string
|
||||
var alias 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(&alias, "alias", "", "")
|
||||
|
||||
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.EnableHttpsCertificatesRequest{
|
||||
TailnetId: tailnet.Id,
|
||||
Alias: alias,
|
||||
}
|
||||
|
||||
if _, err := client.EnableHttpsCertificates(context.Background(), connect.NewRequest(&req)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func disableHttpsCommand() *coral.Command {
|
||||
command := &coral.Command{
|
||||
Use: "disable-https",
|
||||
Short: "Disable HTTPS certificates",
|
||||
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.DisableHttpsCertificatesRequest{
|
||||
TailnetId: tailnet.Id,
|
||||
}
|
||||
|
||||
if _, err := client.DisableHttpsCertificates(context.Background(), connect.NewRequest(&req)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
+33
-14
@@ -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.ListTailnetRequest{}))
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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"
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
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 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+436
-13
@@ -3,21 +3,144 @@ 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())
|
||||
|
||||
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%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 +155,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 +178,39 @@ 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 listMachinesCommand() *coral.Command {
|
||||
command := &coral.Command{
|
||||
Use: "list",
|
||||
@@ -66,15 +223,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 +239,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", "EPHEMERAL", "LAST_SEEN", "TAGS")
|
||||
for _, m := range resp.Msg.Machines {
|
||||
var lastSeen = "N/A"
|
||||
if m.Connected {
|
||||
lastSeen = "Connected"
|
||||
@@ -99,7 +256,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.Ephemeral, lastSeen, strings.Join(m.Tags, ","))
|
||||
}
|
||||
tbl.Print()
|
||||
|
||||
@@ -108,3 +265,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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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.GetMachineRoutesResponse) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
+530
-17
@@ -2,20 +2,46 @@ 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(getIAMPolicyCommand())
|
||||
command.AddCommand(setIAMPolicyCommand())
|
||||
command.AddCommand(enableHttpsCommand())
|
||||
command.AddCommand(disableHttpsCommand())
|
||||
command.AddCommand(enableServiceCollectionCommand())
|
||||
command.AddCommand(disableServiceCollectionCommand())
|
||||
command.AddCommand(enableFileSharingCommand())
|
||||
command.AddCommand(disableFileSharingCommand())
|
||||
command.AddCommand(enableSSHCommand())
|
||||
command.AddCommand(disableSSHCommand())
|
||||
command.AddCommand(getDERPMap())
|
||||
command.AddCommand(setDERPMap())
|
||||
command.AddCommand(resetDERPMap())
|
||||
|
||||
return command
|
||||
}
|
||||
@@ -23,8 +49,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 +58,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.ListTailnetRequest{}))
|
||||
|
||||
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 +84,74 @@ 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 == "" && email == "" && domain == "" {
|
||||
return fmt.Errorf("at least flag --name, --email or --domain 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 tailnetName = ""
|
||||
var iamPolicy = api.IAMPolicy{}
|
||||
|
||||
if len(domain) != 0 {
|
||||
domainToLower := strings.ToLower(domain)
|
||||
tailnetName = domainToLower
|
||||
iamPolicy = api.IAMPolicy{
|
||||
Filters: []string{fmt.Sprintf("domain == %s", domainToLower)},
|
||||
}
|
||||
}
|
||||
|
||||
if len(email) != 0 {
|
||||
emailToLower := strings.ToLower(email)
|
||||
tailnetName = emailToLower
|
||||
iamPolicy = api.IAMPolicy{
|
||||
Emails: []string{emailToLower},
|
||||
Roles: map[string]string{
|
||||
emailToLower: string(idomain.UserRoleAdmin),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if len(name) != 0 {
|
||||
tailnetName = name
|
||||
}
|
||||
|
||||
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: tailnetName,
|
||||
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 +159,452 @@ 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.EnabledFileSharing(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 network’s 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.EnabledServiceCollection(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 network’s 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.EnabledSSH(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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
|
||||
+171
-76
@@ -2,117 +2,201 @@ package config
|
||||
|
||||
import (
|
||||
"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
|
||||
certDNSSuffix = ""
|
||||
)
|
||||
|
||||
func KeepAliveInterval() time.Duration {
|
||||
return keepAliveInterval
|
||||
}
|
||||
|
||||
func MagicDNSSuffix() string {
|
||||
return magicDNSSuffix
|
||||
}
|
||||
|
||||
func CertDNSSuffix() string {
|
||||
return certDNSSuffix
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
envCfg := &Config{}
|
||||
if err := env.Parse(envCfg, env.Options{Prefix: "IONSCALE_"}); 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 := mergo.Merge(cfg, envCfg, mergo.WithOverride); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keepAliveInterval = cfg.PollNet.KeepAliveInterval
|
||||
magicDNSSuffix = cfg.DNS.MagicDNSSuffix
|
||||
|
||||
if cfg.DNS.Provider.Zone != "" {
|
||||
if cfg.DNS.Provider.Subdomain == "" {
|
||||
certDNSSuffix = cfg.DNS.Provider.Zone
|
||||
} else {
|
||||
certDNSSuffix = fmt.Sprintf("%s.%s", cfg.DNS.Provider.Subdomain, cfg.DNS.Provider.Zone)
|
||||
}
|
||||
}
|
||||
|
||||
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" env:"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"`
|
||||
Subdomain string `yaml:"subdomain"`
|
||||
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 +204,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
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ 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/broker"
|
||||
"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 +18,118 @@ import (
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func OpenDB(config *config.Database, logger hclog.Logger) (*gorm.DB, domain.Repository, error) {
|
||||
gormDB, err := createDB(config, logger)
|
||||
type db interface {
|
||||
DB() *gorm.DB
|
||||
Lock() error
|
||||
Unlock() error
|
||||
UnlockErr(error) error
|
||||
}
|
||||
|
||||
func OpenDB(config *config.Database, logger hclog.Logger) (domain.Repository, broker.Pubsub, error) {
|
||||
db, pubsub, err := createDB(config, logger)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
repository := domain.NewRepository(gormDB)
|
||||
repository := domain.NewRepository(db.DB())
|
||||
|
||||
if err := migrate(gormDB, repository); err != nil {
|
||||
if err := db.Lock(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return gormDB, repository, nil
|
||||
if err := db.UnlockErr(migrate(db.DB())); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return repository, pubsub, nil
|
||||
}
|
||||
|
||||
func createDB(config *config.Database, logger hclog.Logger) (*gorm.DB, error) {
|
||||
func createDB(config *config.Database, logger hclog.Logger) (db, broker.Pubsub, error) {
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: &GormLoggerAdapter{logger: logger.Named("db")},
|
||||
}
|
||||
|
||||
return gorm.Open(sqlite.Open(config.Url), gormConfig)
|
||||
switch config.Type {
|
||||
case "sqlite", "sqlite3":
|
||||
db, err := newSqliteDB(config, gormConfig)
|
||||
return db, broker.NewPubsubInMemory(), err
|
||||
case "postgres", "postgresql":
|
||||
db, err := newPostgresDB(config, gormConfig)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
stdDB, err := db.DB().DB()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pubsub, err := broker.NewPubsub(context.TODO(), stdDB, config.Url)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return db, pubsub, err
|
||||
}
|
||||
|
||||
return nil, 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+25
@@ -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,18 @@
|
||||
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(),
|
||||
}
|
||||
return migrations
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
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.DNSProvider) (Provider, error) {
|
||||
if len(config.Zone) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch config.Name {
|
||||
case "azure":
|
||||
return configureAzureProvider(config.Zone, config.Configuration)
|
||||
case "cloudflare":
|
||||
return configureCloudflareProvider(config.Zone, config.Configuration)
|
||||
case "digitalocean":
|
||||
return configureDigitalOceanProvider(config.Zone, config.Configuration)
|
||||
case "googleclouddns":
|
||||
return configureGoogleCloudDNSProvider(config.Zone, config.Configuration)
|
||||
case "route53":
|
||||
return configureRoute53Provider(config.Zone, config.Configuration)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown dns provider: %s", config.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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 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",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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, u *User) []*tailcfg.SSHPrincipal {
|
||||
var allSrcIPsSet = &StringSet{}
|
||||
for _, alias := range aliases {
|
||||
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, &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, 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
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
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 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
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
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_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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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").First(&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.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
|
||||
}
|
||||
@@ -36,8 +36,8 @@ 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"`
|
||||
ID uint64 `gorm:"primary_key"`
|
||||
Key string
|
||||
Hash string
|
||||
Ephemeral bool
|
||||
Tags Tags
|
||||
@@ -52,6 +52,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 +85,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 +114,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 {
|
||||
|
||||
@@ -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).First(&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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
+277
-15
@@ -8,29 +8,33 @@ 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
|
||||
|
||||
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 +46,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 {
|
||||
@@ -193,6 +430,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 +502,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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
|
||||
}
|
||||
|
||||
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).First(&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).First(&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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).First(&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
|
||||
}
|
||||
@@ -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").First(&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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Alias *string
|
||||
DNSConfig DNSConfig
|
||||
IAMPolicy IAMPolicy
|
||||
ACLPolicy ACLPolicy
|
||||
DERPMap DERPMap
|
||||
ServiceCollectionEnabled bool
|
||||
FileSharingEnabled bool
|
||||
SSHEnabled 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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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").First(&m, "id = ? and user_type = ?", userID, UserTypePerson)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,48 +1,283 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/jsiebens/ionscale/internal/addr"
|
||||
"github.com/jsiebens/ionscale/internal/auth"
|
||||
"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) StartCliAuth(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
flow := c.Param("flow")
|
||||
key := c.Param("key")
|
||||
|
||||
if flow == "c" {
|
||||
if s, err := h.repository.GetAuthenticationRequest(ctx, key); err != nil || s == nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
}
|
||||
|
||||
if flow == "s" {
|
||||
if s, err := h.repository.GetSSHActionRequest(ctx, key); err != nil || s == nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
}
|
||||
|
||||
if h.authProvider == nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
state, err := h.createState(flow, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
redirectUrl := h.authProvider.GetLoginURL(h.config.CreateUrl("/a/callback"), state)
|
||||
|
||||
return c.Redirect(http.StatusFound, redirectUrl)
|
||||
}
|
||||
|
||||
func (h *AuthenticationHandlers) StartAuth(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
key := c.Param("key")
|
||||
|
||||
if req, err := h.repository.GetRegistrationRequestByKey(ctx, key); err != nil || req == nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
csrf := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
|
||||
return c.Render(http.StatusOK, "auth.html", &AuthFormData{ProviderAvailable: h.authProvider != nil, Csrf: csrf})
|
||||
}
|
||||
|
||||
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 {
|
||||
req, err := h.repository.GetRegistrationRequestByKey(ctx, key)
|
||||
if err != nil || req == nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
user, err := h.exchangeUser(code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
account, _, err := h.repository.GetOrCreateAccount(ctx, user.ID, user.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
policy := machine.Tailnet.ACLPolicy
|
||||
|
||||
if machine.HasTags() && policy.IsTagOwner(machine.Tags, &domain.User{Name: account.LoginName, UserType: domain.UserTypePerson}) {
|
||||
sshActionReq.Action = "accept"
|
||||
if err := h.repository.SaveSSHActionRequest(ctx, sshActionReq); err != nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
return c.Redirect(http.StatusFound, "/a/success")
|
||||
}
|
||||
|
||||
if machine.User.AccountID != nil && *machine.User.AccountID == account.ID {
|
||||
sshActionReq.Action = "accept"
|
||||
if err := h.repository.SaveSSHActionRequest(ctx, sshActionReq); err != nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
return c.Redirect(http.StatusFound, "/a/success")
|
||||
}
|
||||
|
||||
sshActionReq.Action = "reject"
|
||||
if err := h.repository.SaveSSHActionRequest(ctx, sshActionReq); err != nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
return c.Redirect(http.StatusFound, "/a/error?e=nmo")
|
||||
}
|
||||
|
||||
tailnets, err := h.listAvailableTailnets(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
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 c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
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 c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
if state.Flow == "r" {
|
||||
req, err := h.repository.GetRegistrationRequestByKey(ctx, state.Key)
|
||||
if err != nil || req == nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
return h.endMachineRegistrationFlow(c, req, state)
|
||||
}
|
||||
|
||||
req, err := h.repository.GetAuthenticationRequest(ctx, state.Key)
|
||||
if err != nil || req == nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
return h.endCliAuthenticationFlow(c, req, state)
|
||||
}
|
||||
|
||||
func (h *AuthenticationHandlers) Success(c echo.Context) error {
|
||||
@@ -54,48 +289,170 @@ 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 {
|
||||
var form TailnetSelectionForm
|
||||
if err := c.Bind(&form); err != nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
preq := preqItem.(*pendingMachineRegistrationRequest)
|
||||
req := preq.request
|
||||
machineKey := preq.machineKey
|
||||
nodeKey := req.NodeKey.String()
|
||||
account, err := h.repository.GetAccount(ctx, form.AccountID)
|
||||
if err != nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
authKey, err := h.repository.LoadAuthKey(ctx, authKeyParam)
|
||||
// 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 err
|
||||
}
|
||||
if err := rp.SaveAuthenticationRequest(ctx, req); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Redirect(http.StatusFound, "/a/success")
|
||||
}
|
||||
|
||||
tailnet, err := h.repository.GetTailnet(ctx, form.TailnetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if authKey == nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error?e=iak")
|
||||
user, _, err := h.repository.GetOrCreateUserWithAccount(ctx, tailnet, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tailnet := authKey.Tailnet
|
||||
user := authKey.User
|
||||
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 err
|
||||
}
|
||||
|
||||
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 c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
req := tailcfg.RegisterRequest(registrationRequest.Data)
|
||||
machineKey := registrationRequest.MachineKey
|
||||
nodeKey := req.NodeKey.String()
|
||||
|
||||
var tailnet *domain.Tailnet
|
||||
var user *domain.User
|
||||
var ephemeral bool
|
||||
var tags = []string{}
|
||||
|
||||
if form.AuthKey != "" {
|
||||
authKey, err := h.repository.LoadAuthKey(ctx, form.AuthKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if authKey == nil {
|
||||
|
||||
registrationRequest.Authenticated = false
|
||||
registrationRequest.Error = "invalid auth key"
|
||||
|
||||
if err := h.repository.SaveRegistrationRequest(ctx, registrationRequest); err != nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
return c.Redirect(http.StatusFound, "/a/error?e=iak")
|
||||
}
|
||||
|
||||
tailnet = &authKey.Tailnet
|
||||
user = &authKey.User
|
||||
tags = authKey.Tags
|
||||
ephemeral = authKey.Ephemeral
|
||||
} else {
|
||||
selectedTailnet, err := h.repository.GetTailnet(ctx, form.TailnetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
account, err := h.repository.GetAccount(ctx, form.AccountID)
|
||||
if err != nil {
|
||||
return c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
|
||||
selectedUser, _, err := h.repository.GetOrCreateUserWithAccount(ctx, selectedTailnet, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user = selectedUser
|
||||
tailnet = selectedTailnet
|
||||
ephemeral = false
|
||||
}
|
||||
|
||||
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 c.Redirect(http.StatusFound, "/a/error")
|
||||
}
|
||||
return c.Redirect(http.StatusFound, "/a/error?e=nto")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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...)
|
||||
|
||||
@@ -106,32 +463,31 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
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
|
||||
}
|
||||
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...)
|
||||
|
||||
@@ -145,19 +501,68 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
|
||||
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 {
|
||||
err = h.repository.Transaction(func(rp domain.Repository) error {
|
||||
registrationRequest.Authenticated = true
|
||||
registrationRequest.Error = ""
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
return c.Redirect(http.StatusFound, "/a/success")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/jsiebens/ionscale/internal/bind"
|
||||
"github.com/jsiebens/ionscale/internal/dns"
|
||||
"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 err
|
||||
}
|
||||
|
||||
req := &tailcfg.SetDNSRequest{}
|
||||
if err := binder.BindRequest(c, req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if h.provider == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
if err := h.provider.SetRecord(ctx, req.Type, req.Name, req.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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/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 err
|
||||
}
|
||||
|
||||
pub := jose.JSONWebKey{Key: keySet.Key.Public(), KeyID: keySet.Key.Id, Algorithm: "RS256", Use: "sig"}
|
||||
set := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{pub}}
|
||||
return c.JSON(http.StatusOK, set)
|
||||
}
|
||||
|
||||
func (h *IDTokenHandlers) FetchToken(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
keySet, err := h.repository.GetJSONWebKeySet(c.Request().Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
binder, err := h.createBinder(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req := &tailcfg.TokenRequest{}
|
||||
if err := binder.BindRequest(c, req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
machineKey := binder.Peer().String()
|
||||
nodeKey := req.NodeKey.String()
|
||||
|
||||
var m *domain.Machine
|
||||
m, err = h.repository.GetMachineByKeys(ctx, machineKey, nodeKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
@@ -26,7 +25,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -4,28 +4,25 @@ 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/domain"
|
||||
"github.com/jsiebens/ionscale/internal/mapping"
|
||||
"github.com/labstack/echo/v4"
|
||||
"net/http"
|
||||
"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,
|
||||
brokers broker.Pubsub,
|
||||
repository domain.Repository,
|
||||
offlineTimers *OfflineTimers) *PollNetMapHandler {
|
||||
|
||||
handler := &PollNetMapHandler{
|
||||
createBinder: createBinder,
|
||||
brokers: brokers.Get,
|
||||
brokers: brokers,
|
||||
repository: repository,
|
||||
offlineTimers: offlineTimers,
|
||||
}
|
||||
@@ -36,7 +33,7 @@ func NewPollNetMapHandler(
|
||||
type PollNetMapHandler struct {
|
||||
createBinder bind.Factory
|
||||
repository domain.Repository
|
||||
brokers func(uint64) broker.Broker
|
||||
brokers broker.Pubsub
|
||||
offlineTimers *OfflineTimers
|
||||
}
|
||||
|
||||
@@ -89,39 +86,38 @@ func (h *PollNetMapHandler) handleUpdate(c echo.Context, binder bind.Binder, m *
|
||||
tailnetID := m.TailnetID
|
||||
machineID := m.ID
|
||||
|
||||
tailnetBroker := h.brokers(tailnetID)
|
||||
tailnetBroker.SignalPeerUpdated(machineID)
|
||||
h.brokers.Publish(tailnetID, &broker.Signal{PeerUpdated: &machineID})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
updateChan := make(chan *broker.Signal, 20)
|
||||
client := broker.NewClient(machineID, updateChan)
|
||||
|
||||
tailnetBroker.AddClient(&client)
|
||||
unsubscribe, err := h.brokers.Subscribe(tailnetID, updateChan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
keepAliveTicker := time.NewTicker(keepAliveInterval)
|
||||
keepAliveTicker := time.NewTicker(config.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 {
|
||||
@@ -129,20 +125,24 @@ func (h *PollNetMapHandler) handleUpdate(c echo.Context, binder bind.Binder, m *
|
||||
}
|
||||
c.Response().Flush()
|
||||
|
||||
connectedDevices.WithLabelValues(m.Tailnet.Name).Inc()
|
||||
|
||||
defer func() {
|
||||
tailnetBroker.RemoveClient(machineID)
|
||||
connectedDevices.WithLabelValues(m.Tailnet.Name).Dec()
|
||||
unsubscribe()
|
||||
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 {
|
||||
@@ -164,7 +164,7 @@ 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
|
||||
@@ -193,7 +193,7 @@ func (h *PollNetMapHandler) handleReadOnly(c echo.Context, binder bind.Binder, m
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -218,70 +218,107 @@ 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()
|
||||
|
||||
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)
|
||||
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
|
||||
var validPeers []domain.Machine
|
||||
|
||||
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) {
|
||||
validPeers = append(validPeers, peer)
|
||||
n, u, err := mapping.ToNode(&peer, tailnet, true)
|
||||
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, validPeers, &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, validPeers, &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,13 +329,13 @@ 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 {
|
||||
func NewOfflineTimers(repository domain.Repository, pubsub broker.Pubsub) *OfflineTimers {
|
||||
return &OfflineTimers{
|
||||
repository: repository,
|
||||
brokers: brokers.Get,
|
||||
pubsub: pubsub,
|
||||
data: make(map[uint64]*time.Timer),
|
||||
startCh: make(chan [2]uint64),
|
||||
stopCh: make(chan uint64),
|
||||
@@ -307,7 +344,7 @@ func NewOfflineTimers(repository domain.Repository, brokers *broker.BrokerPool)
|
||||
|
||||
type OfflineTimers struct {
|
||||
repository domain.Repository
|
||||
brokers func(uint64) broker.Broker
|
||||
pubsub broker.Pubsub
|
||||
data map[uint64]*time.Timer
|
||||
stopCh chan uint64
|
||||
startCh chan [2]uint64
|
||||
@@ -331,13 +368,11 @@ func (o *OfflineTimers) scheduleOfflineMessage(tailnetID, machineID uint64) {
|
||||
delete(o.data, machineID)
|
||||
}
|
||||
|
||||
timer := time.NewTimer(10 * time.Second)
|
||||
timer := time.NewTimer(config.KeepAliveInterval())
|
||||
go func() {
|
||||
<-timer.C
|
||||
if !o.brokers(tailnetID).IsConnected(machineID) {
|
||||
o.brokers(tailnetID).SignalPeerUpdated(machineID)
|
||||
o.stopCh <- machineID
|
||||
}
|
||||
o.pubsub.Publish(tailnetID, &broker.Signal{PeerUpdated: &machineID})
|
||||
o.stopCh <- machineID
|
||||
}()
|
||||
|
||||
o.data[machineID] = timer
|
||||
@@ -350,3 +385,9 @@ func (o *OfflineTimers) cancelOfflineMessage(machineID uint64) {
|
||||
delete(o.data, machineID)
|
||||
}
|
||||
}
|
||||
|
||||
func optBool(v bool) opt.Bool {
|
||||
b := opt.Bool("")
|
||||
b.Set(v)
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -12,15 +12,15 @@ const (
|
||||
inactivityTimeout = 30 * time.Minute
|
||||
)
|
||||
|
||||
func NewReaper(brokers *broker.BrokerPool, repository domain.Repository) *Reaper {
|
||||
func NewReaper(brokers broker.Pubsub, repository domain.Repository) *Reaper {
|
||||
return &Reaper{
|
||||
brokers: brokers,
|
||||
pubsub: brokers,
|
||||
repository: repository,
|
||||
}
|
||||
}
|
||||
|
||||
type Reaper struct {
|
||||
brokers *broker.BrokerPool
|
||||
pubsub broker.Pubsub
|
||||
repository domain.Repository
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func (r *Reaper) reapInactiveEphemeralNodes() {
|
||||
|
||||
if len(removedNodes) != 0 {
|
||||
for i, p := range removedNodes {
|
||||
r.brokers.Get(i).SignalPeersRemoved(p)
|
||||
r.pubsub.Publish(i, &broker.Signal{PeersRemoved: p})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import (
|
||||
"context"
|
||||
"github.com/jsiebens/ionscale/internal/addr"
|
||||
"github.com/jsiebens/ionscale/internal/bind"
|
||||
"github.com/jsiebens/ionscale/internal/broker"
|
||||
"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"
|
||||
"inet.af/netaddr"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/dnsname"
|
||||
"time"
|
||||
@@ -19,26 +19,21 @@ import (
|
||||
func NewRegistrationHandlers(
|
||||
createBinder bind.Factory,
|
||||
config *config.Config,
|
||||
repository domain.Repository,
|
||||
pendingMachineRegistrationRequests *cache.Cache) *RegistrationHandlers {
|
||||
brokers broker.Pubsub,
|
||||
repository domain.Repository) *RegistrationHandlers {
|
||||
return &RegistrationHandlers{
|
||||
createBinder: createBinder,
|
||||
repository: repository,
|
||||
config: config,
|
||||
pendingMachineRegistrationRequests: pendingMachineRegistrationRequests,
|
||||
createBinder: createBinder,
|
||||
pubsub: brokers,
|
||||
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
|
||||
pubsub broker.Pubsub
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func (h *RegistrationHandlers) Register(c echo.Context) error {
|
||||
@@ -65,16 +60,24 @@ func (h *RegistrationHandlers) Register(c echo.Context) error {
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
h.pubsub.Publish(m.TailnetID, &broker.Signal{PeersRemoved: []uint64{m.ID}})
|
||||
} else {
|
||||
if err := h.repository.SaveMachine(ctx, m); err != nil {
|
||||
return err
|
||||
}
|
||||
h.pubsub.Publish(m.TailnetID, &broker.Signal{PeerUpdated: &m.ID})
|
||||
}
|
||||
|
||||
response := tailcfg.RegisterResponse{NodeKeyExpired: true}
|
||||
@@ -106,25 +109,34 @@ func (h *RegistrationHandlers) Register(c echo.Context) error {
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,12 +150,24 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, bi
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -151,13 +175,9 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, bi
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -165,35 +185,34 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, bi
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
@@ -204,14 +223,15 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, bi
|
||||
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 {
|
||||
@@ -222,8 +242,38 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, bi
|
||||
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() {
|
||||
response := tailcfg.RegisterResponse{MachineAuthorized: len(m.Error) != 0, Error: m.Error}
|
||||
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
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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/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 err
|
||||
}
|
||||
|
||||
data := new(sshActionRequestData)
|
||||
if err = c.Bind(data); err != nil {
|
||||
return c.String(http.StatusBadRequest, "bad request")
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+167
-34
@@ -1,34 +1,147 @@
|
||||
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, peers []domain.Machine, tailnet *domain.Tailnet, c *domain.DNSConfig) *tailcfg.DNSConfig {
|
||||
certDNSSuffix := config.CertDNSSuffix()
|
||||
certsEnabled := c.HttpsCertsEnabled && len(certDNSSuffix) != 0
|
||||
|
||||
tailnetDomain := domain.SanitizeTailnetName(tailnet.Name)
|
||||
|
||||
var certDomain = ""
|
||||
if certsEnabled {
|
||||
certDomain = domain.SanitizeTailnetName(*tailnet.Alias)
|
||||
}
|
||||
|
||||
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, fmt.Sprintf("%s.%s", tailnetDomain, config.MagicDNSSuffix()))
|
||||
dnsConfig.Proxied = true
|
||||
|
||||
if certsEnabled {
|
||||
domains = append(domains, fmt.Sprintf("%s.%s", certDomain, certDNSSuffix))
|
||||
certDomains = append(certDomains, fmt.Sprintf("%s.%s.%s", m.CompleteName(), certDomain, certDNSSuffix))
|
||||
}
|
||||
}
|
||||
|
||||
if c.OverrideLocalDNS {
|
||||
dnsConfig.Resolvers = resolvers
|
||||
} else {
|
||||
dnsConfig.FallbackResolvers = resolvers
|
||||
}
|
||||
|
||||
if len(c.Routes) != 0 || certsEnabled {
|
||||
routes := make(map[string][]*dnstype.Resolver)
|
||||
|
||||
if certsEnabled {
|
||||
routes[fmt.Sprintf("%s.", certDNSSuffix)] = nil
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if certsEnabled {
|
||||
var extraRecords = []tailcfg.DNSRecord{{
|
||||
Name: fmt.Sprintf("%s.%s.%s", m.CompleteName(), certDomain, certDNSSuffix),
|
||||
Value: m.IPv4.String(),
|
||||
}}
|
||||
|
||||
for _, p := range peers {
|
||||
extraRecords = append(extraRecords, tailcfg.DNSRecord{
|
||||
Name: fmt.Sprintf("%s.%s.%s", p.CompleteName(), certDomain, certDNSSuffix),
|
||||
Value: p.IPv4.String(),
|
||||
})
|
||||
}
|
||||
|
||||
dnsConfig.ExtraRecords = extraRecords
|
||||
}
|
||||
|
||||
return dnsConfig
|
||||
}
|
||||
|
||||
func ToNode(m *domain.Machine, tailnet *domain.Tailnet, peer bool) (*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 +149,30 @@ 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)
|
||||
}
|
||||
|
||||
allowedIPs = append(allowedIPs, m.AllowIPs...)
|
||||
allowedIPs = append(allowedIPs, m.AutoAllowIPs...)
|
||||
|
||||
var derp string
|
||||
if hostinfo.NetInfo != nil {
|
||||
derp = fmt.Sprintf("127.3.3.40:%d", hostinfo.NetInfo.PreferredDERP)
|
||||
@@ -64,23 +180,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,7 +202,8 @@ 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(),
|
||||
|
||||
@@ -97,18 +211,34 @@ func ToNode(m *domain.Machine, connected bool) (*tailcfg.Node, error) {
|
||||
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
|
||||
if m.LastSeen != nil {
|
||||
l := m.LastSeen.UTC()
|
||||
online := m.LastSeen.After(time.Now().Add(-config.KeepAliveInterval()))
|
||||
n.LastSeen = &l
|
||||
n.Online = &online
|
||||
}
|
||||
|
||||
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 +250,13 @@ 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 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 profiles
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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...)}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/bufbuild/connect-go"
|
||||
"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, handler apiconnect.IonscaleServiceHandler) (string, http.Handler) {
|
||||
interceptors := connect.WithInterceptors(service.AuthenticationInterceptor(systemAdminKey, repository))
|
||||
return apiconnect.NewIonscaleServiceHandler(handler, interceptors)
|
||||
}
|
||||
+204
-41
@@ -1,113 +1,276 @@
|
||||
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/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, brokers, err := database.OpenDB(&c.Database, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serverKey, err := config.ReadServerKeys()
|
||||
defaultControlKeys, err := repository.GetControlKeys(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serverKey, err := c.ReadServerKeys(defaultControlKeys)
|
||||
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)
|
||||
|
||||
go offlineTimers.Start()
|
||||
go reaper.Start()
|
||||
|
||||
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.Provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
createPeerHandler := func(p key.MachinePublic) http.Handler {
|
||||
registrationHandlers := handlers.NewRegistrationHandlers(bind.DefaultBinder(p), config, repository, pendingMachineRegistrationRequests)
|
||||
registrationHandlers := handlers.NewRegistrationHandlers(bind.DefaultBinder(p), c, brokers, repository)
|
||||
pollNetMapHandler := handlers.NewPollNetMapHandler(bind.DefaultBinder(p), brokers, repository, offlineTimers)
|
||||
dnsHandlers := handlers.NewDNSHandlers(bind.DefaultBinder(p), dnsProvider)
|
||||
idTokenHandlers := handlers.NewIDTokenHandlers(bind.DefaultBinder(p), c, repository)
|
||||
sshActionHandlers := handlers.NewSSHActionHandlers(bind.DefaultBinder(p), c, repository)
|
||||
|
||||
e := echo.New()
|
||||
e.Use(EchoLogger(logger))
|
||||
e.Use(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)
|
||||
registrationHandlers := handlers.NewRegistrationHandlers(bind.BoxBinder(serverKey.LegacyControlKey), c, brokers, repository)
|
||||
pollNetMapHandler := handlers.NewPollNetMapHandler(bind.BoxBinder(serverKey.LegacyControlKey), brokers, repository, offlineTimers)
|
||||
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,
|
||||
)
|
||||
|
||||
rpcService := service.NewService(c, authProvider, repository, brokers)
|
||||
rpcPath, rpcHandler := NewRpcHandler(serverKey.SystemAdminKey, repository, rpcService)
|
||||
|
||||
p := echo_prometheus.NewPrometheus("http", nil)
|
||||
|
||||
e := echo.New()
|
||||
e.Renderer = templates.NewTemplates()
|
||||
e.Use(EchoRecover(logger))
|
||||
e.Use(EchoLogger(logger))
|
||||
e.Use(p.HandlerFunc)
|
||||
metricsHandler := echo.New()
|
||||
p.SetMetricsPath(metricsHandler)
|
||||
|
||||
m := echo.New()
|
||||
p.SetMetricsPath(m)
|
||||
nonTlsAppHandler := echo.New()
|
||||
nonTlsAppHandler.Use(EchoRecover(logger))
|
||||
nonTlsAppHandler.Use(EchoLogger(logger))
|
||||
nonTlsAppHandler.Use(p.HandlerFunc)
|
||||
nonTlsAppHandler.POST("/ts2021", noiseHandlers.Upgrade)
|
||||
nonTlsAppHandler.Any("/*", handlers.HttpRedirectHandler(c.Tls))
|
||||
|
||||
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 := echo.New()
|
||||
tlsAppHandler.Pre(handlers.HttpsRedirect(c.Tls))
|
||||
tlsAppHandler.Renderer = templates.NewTemplates()
|
||||
tlsAppHandler.Use(EchoRecover(logger))
|
||||
tlsAppHandler.Use(EchoLogger(logger))
|
||||
tlsAppHandler.Use(p.HandlerFunc)
|
||||
|
||||
auth := e.Group("/a")
|
||||
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 := tlsAppHandler.Group("/a")
|
||||
auth.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
||||
TokenLookup: "form:_csrf",
|
||||
}))
|
||||
auth.GET("/:key", authenticationHandlers.StartAuth)
|
||||
auth.POST("/:key", authenticationHandlers.StartAuth)
|
||||
auth.POST("/:key", authenticationHandlers.ProcessAuth)
|
||||
auth.GET("/:flow/:key", authenticationHandlers.StartCliAuth)
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/jsiebens/ionscale/internal/broker"
|
||||
"github.com/jsiebens/ionscale/internal/domain"
|
||||
"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, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet does not exist"))
|
||||
}
|
||||
|
||||
var policy api.ACLPolicy
|
||||
if err := mapping.CopyViaJson(&tailnet.ACLPolicy, &policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet does not exist"))
|
||||
}
|
||||
|
||||
var policy domain.ACLPolicy
|
||||
if err := mapping.CopyViaJson(req.Msg.Policy, &policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tailnet.ACLPolicy = policy
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(tailnet.ID, &broker.Signal{ACLUpdated: true})
|
||||
|
||||
return connect.NewResponse(&api.SetACLPolicyResponse{}), nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/jsiebens/ionscale/internal/domain"
|
||||
"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.AuthenticationRequest], stream *connect.ServerStream[api.AuthenticationResponse]) error {
|
||||
if s.authProvider == nil {
|
||||
return connect.NewError(connect.CodeFailedPrecondition, errors.New("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 err
|
||||
}
|
||||
|
||||
if err := stream.Send(&api.AuthenticationResponse{AuthUrl: authUrl}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 || m == nil {
|
||||
return connect.NewError(connect.CodeInternal, errors.New("something went wrong"))
|
||||
}
|
||||
|
||||
if len(m.Token) != 0 {
|
||||
if err := stream.Send(&api.AuthenticationResponse{Token: m.Token, TailnetId: m.TailnetID}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(m.Error) != 0 {
|
||||
return connect.NewError(connect.CodePermissionDenied, errors.New(m.Error))
|
||||
}
|
||||
|
||||
if err := stream.Send(&api.AuthenticationResponse{AuthUrl: authUrl}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case <-notify:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
-31
@@ -2,30 +2,51 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
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
|
||||
}
|
||||
|
||||
if tailnet == nil {
|
||||
return nil, status.Error(codes.NotFound, "")
|
||||
if key == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("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, errors.New("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,54 +54,114 @@ 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, errors.New("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
|
||||
}
|
||||
|
||||
if tailnet == nil {
|
||||
return nil, status.Error(codes.NotFound, "")
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
response := api.ListAuthKeysResponse{}
|
||||
|
||||
if principal.IsSystemAdmin() {
|
||||
authKeys, err := s.repository.ListAuthKeys(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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, err
|
||||
}
|
||||
|
||||
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, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
if principal.User == nil && len(req.Msg.Tags) == 0 {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("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, err
|
||||
}
|
||||
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("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, err
|
||||
}
|
||||
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, tags, expiresAt)
|
||||
|
||||
if err := s.repository.SaveAuthKey(ctx, authKey); err != nil {
|
||||
return nil, err
|
||||
@@ -92,6 +173,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 +182,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 {
|
||||
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, err
|
||||
}
|
||||
return &api.DeleteAuthKeyResponse{}, nil
|
||||
|
||||
if key == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("auth key not found"))
|
||||
}
|
||||
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(key.UserID) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
if _, err := s.repository.DeleteAuthKey(ctx, req.Msg.AuthKeyId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return connect.NewResponse(&api.DeleteAuthKeyResponse{}), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/jsiebens/ionscale/internal/broker"
|
||||
"github.com/jsiebens/ionscale/internal/domain"
|
||||
"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, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
dm, err := s.repository.GetDERPMap(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(dm.DERPMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.GetDefaultDERPMapResponse{Value: raw}), nil
|
||||
}
|
||||
|
||||
func (s *Service) SetDefaultDERPMap(ctx context.Context, req *connect.Request[api.SetDefaultDERPMapRequest]) (*connect.Response[api.SetDefaultDERPMapResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
var derpMap tailcfg.DERPMap
|
||||
if err := json.Unmarshal(req.Msg.Value, &derpMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dp := domain.DERPMap{
|
||||
Checksum: util.Checksum(&derpMap),
|
||||
DERPMap: derpMap,
|
||||
}
|
||||
|
||||
if err := s.repository.SetDERPMap(ctx, &dp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tailnets, err := s.repository.ListTailnets(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, t := range tailnets {
|
||||
s.pubsub.Publish(t.ID, &broker.Signal{})
|
||||
}
|
||||
|
||||
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, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
dp := domain.DERPMap{}
|
||||
|
||||
if err := s.repository.SetDERPMap(ctx, &dp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tailnets, err := s.repository.ListTailnets(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, t := range tailnets {
|
||||
s.pubsub.Publish(t.ID, &broker.Signal{})
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.ResetDefaultDERPMapResponse{}), nil
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/jsiebens/ionscale/internal/broker"
|
||||
"github.com/jsiebens/ionscale/internal/config"
|
||||
"github.com/jsiebens/ionscale/internal/domain"
|
||||
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
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, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
dnsConfig := tailnet.DNSConfig
|
||||
tailnetDomain := domain.SanitizeTailnetName(tailnet.Name)
|
||||
|
||||
resp := &api.GetDNSConfigResponse{
|
||||
Config: &api.DNSConfig{
|
||||
MagicDns: dnsConfig.MagicDNS,
|
||||
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, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
dnsConfig := req.Msg.Config
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
tailnet.DNSConfig = domain.DNSConfig{
|
||||
MagicDNS: dnsConfig.MagicDns,
|
||||
OverrideLocalDNS: dnsConfig.OverrideLocalDns,
|
||||
Nameservers: dnsConfig.Nameservers,
|
||||
Routes: apiRoutesToDomainRoutes(dnsConfig.Routes),
|
||||
}
|
||||
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(tailnet.ID, &broker.Signal{DNSUpdated: true})
|
||||
|
||||
resp := &api.SetDNSConfigResponse{Config: dnsConfig}
|
||||
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *Service) EnableHttpsCertificates(ctx context.Context, req *connect.Request[api.EnableHttpsCertificatesRequest]) (*connect.Response[api.EnableHttpsCertificatesResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
alias := dnsname.SanitizeLabel(req.Msg.Alias)
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
if !tailnet.DNSConfig.MagicDNS {
|
||||
return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("MagicDNS must be enabled for this tailnet"))
|
||||
}
|
||||
|
||||
if tailnet.Alias == nil && len(alias) == 0 {
|
||||
return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("when enabling HTTPS certificates for the first time, a Tailnet alias is required"))
|
||||
}
|
||||
|
||||
if tailnet.Alias != nil && len(alias) != 0 && *tailnet.Alias != alias {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("a Tailnet alias was already configured previously"))
|
||||
}
|
||||
|
||||
tailnet.DNSConfig.HttpsCertsEnabled = true
|
||||
if tailnet.Alias == nil && len(alias) != 0 {
|
||||
t, err := s.repository.GetTailnetByAlias(ctx, alias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if t != nil && t.ID != tailnet.ID {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("given alias is already in use"))
|
||||
}
|
||||
|
||||
tailnet.Alias = &alias
|
||||
}
|
||||
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(tailnet.ID, &broker.Signal{DNSUpdated: true})
|
||||
|
||||
return connect.NewResponse(&api.EnableHttpsCertificatesResponse{}), nil
|
||||
}
|
||||
|
||||
func (s *Service) DisableHttpsCertificates(ctx context.Context, req *connect.Request[api.DisableHttpsCertificatesRequest]) (*connect.Response[api.DisableHttpsCertificatesResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
tailnet.DNSConfig.HttpsCertsEnabled = false
|
||||
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(tailnet.ID, &broker.Signal{DNSUpdated: true})
|
||||
|
||||
return connect.NewResponse(&api.DisableHttpsCertificatesResponse{}), 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
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/jsiebens/ionscale/internal/domain"
|
||||
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, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet does not exist"))
|
||||
}
|
||||
|
||||
policy := &api.IAMPolicy{
|
||||
Subs: tailnet.IAMPolicy.Subs,
|
||||
Emails: tailnet.IAMPolicy.Emails,
|
||||
Filters: tailnet.IAMPolicy.Filters,
|
||||
Roles: domainRolesMapToApiRolesMap(tailnet.IAMPolicy.Roles),
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.GetIAMPolicyResponse{Policy: policy}), nil
|
||||
}
|
||||
|
||||
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, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet does not exist"))
|
||||
}
|
||||
|
||||
tailnet.IAMPolicy = domain.IAMPolicy{
|
||||
Subs: req.Msg.Policy.Subs,
|
||||
Emails: req.Msg.Policy.Emails,
|
||||
Filters: req.Msg.Policy.Filters,
|
||||
Roles: apiRolesMapToDomainRolesMap(req.Msg.Policy.Roles),
|
||||
}
|
||||
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.SetIAMPolicyResponse{}), nil
|
||||
}
|
||||
|
||||
func apiRolesMapToDomainRolesMap(values map[string]string) map[string]domain.UserRole {
|
||||
var result = map[string]domain.UserRole{}
|
||||
for k, v := range values {
|
||||
result[k] = domain.UserRole(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func domainRolesMapToApiRolesMap(values map[string]domain.UserRole) map[string]string {
|
||||
var result = map[string]string{}
|
||||
for k, v := range values {
|
||||
result[k] = string(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/jsiebens/ionscale/internal/domain"
|
||||
"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
|
||||
}
|
||||
+341
-39
@@ -2,20 +2,76 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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/broker"
|
||||
"github.com/jsiebens/ionscale/internal/config"
|
||||
"github.com/jsiebens/ionscale/internal/domain"
|
||||
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 := false
|
||||
if m.LastSeen != nil {
|
||||
lastSeen = timestamppb.New(*m.LastSeen)
|
||||
online = m.LastSeen.After(time.Now().Add(-config.KeepAliveInterval()))
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
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, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, status.Error(codes.NotFound, "tailnet does not exist")
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
machines, err := s.repository.ListMachineByTailnet(ctx, tailnet.ID)
|
||||
@@ -25,52 +81,298 @@ func (s *Service) ListMachines(ctx context.Context, req *api.ListMachinesRequest
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return nil, status.Error(codes.NotFound, "machine does not exist")
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("machine not found"))
|
||||
}
|
||||
|
||||
if _, err := s.repository.DeleteMachine(ctx, req.MachineId); err != nil {
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
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, err
|
||||
}
|
||||
|
||||
s.brokers(m.TailnetID).SignalPeersRemoved([]uint64{m.ID})
|
||||
if m == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("machine not found"))
|
||||
}
|
||||
|
||||
return &api.DeleteMachineResponse{}, nil
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
if _, err := s.repository.DeleteMachine(ctx, req.Msg.MachineId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(m.TailnetID, &broker.Signal{PeersRemoved: []uint64{m.ID}})
|
||||
|
||||
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, err
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("machine not found"))
|
||||
}
|
||||
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
timestamp := time.Unix(123, 0)
|
||||
m.ExpiresAt = timestamp
|
||||
m.KeyExpiryDisabled = false
|
||||
|
||||
if err := s.repository.SaveMachine(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(m.TailnetID, &broker.Signal{PeerUpdated: &m.ID})
|
||||
|
||||
return connect.NewResponse(&api.ExpireMachineResponse{}), nil
|
||||
}
|
||||
|
||||
func (s *Service) createMachineRoutesResponse(m *domain.Machine) (*connect.Response[api.GetMachineRoutesResponse], error) {
|
||||
response := api.GetMachineRoutesResponse{
|
||||
AdvertisedRoutes: m.AdvertisedPrefixes(),
|
||||
EnabledRoutes: m.AllowedPrefixes(),
|
||||
AdvertisedExitNode: m.IsAdvertisedExitNode(),
|
||||
EnabledExitNode: m.IsAllowedExitNode(),
|
||||
}
|
||||
|
||||
return connect.NewResponse(&response), 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, err
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("machine not found"))
|
||||
}
|
||||
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
return s.createMachineRoutesResponse(m)
|
||||
}
|
||||
|
||||
func (s *Service) EnableMachineRoutes(ctx context.Context, req *connect.Request[api.EnableMachineRoutesRequest]) (*connect.Response[api.GetMachineRoutesResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
|
||||
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("machine not found"))
|
||||
}
|
||||
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("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, err
|
||||
}
|
||||
allowIPs.Add(prefix)
|
||||
}
|
||||
|
||||
m.AllowIPs = allowIPs.Items()
|
||||
m.AutoAllowIPs = autoAllowIPs.Items()
|
||||
if err := s.repository.SaveMachine(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(m.TailnetID, &broker.Signal{PeerUpdated: &m.ID})
|
||||
|
||||
return s.createMachineRoutesResponse(m)
|
||||
}
|
||||
|
||||
func (s *Service) DisableMachineRoutes(ctx context.Context, req *connect.Request[api.DisableMachineRoutesRequest]) (*connect.Response[api.GetMachineRoutesResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
|
||||
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("machine not found"))
|
||||
}
|
||||
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("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, err
|
||||
}
|
||||
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, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(m.TailnetID, &broker.Signal{PeerUpdated: &m.ID})
|
||||
|
||||
return s.createMachineRoutesResponse(m)
|
||||
}
|
||||
|
||||
func (s *Service) EnableExitNode(ctx context.Context, req *connect.Request[api.EnableExitNodeRequest]) (*connect.Response[api.GetMachineRoutesResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
|
||||
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("machine not found"))
|
||||
}
|
||||
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
if !m.IsAdvertisedExitNode() {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("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, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(m.TailnetID, &broker.Signal{PeerUpdated: &m.ID})
|
||||
|
||||
return s.createMachineRoutesResponse(m)
|
||||
}
|
||||
|
||||
func (s *Service) DisableExitNode(ctx context.Context, req *connect.Request[api.DisableExitNodeRequest]) (*connect.Response[api.GetMachineRoutesResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
|
||||
m, err := s.repository.GetMachine(ctx, req.Msg.MachineId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("machine not found"))
|
||||
}
|
||||
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
if !m.IsAdvertisedExitNode() {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("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, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(m.TailnetID, &broker.Signal{PeerUpdated: &m.ID})
|
||||
|
||||
return s.createMachineRoutesResponse(m)
|
||||
}
|
||||
|
||||
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, err
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("machine not found"))
|
||||
}
|
||||
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(m.TailnetID) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
m.KeyExpiryDisabled = req.Msg.Disabled
|
||||
|
||||
if err := s.repository.SaveMachine(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(m.TailnetID, &broker.Signal{PeerUpdated: &m.ID})
|
||||
|
||||
return connect.NewResponse(&api.SetMachineKeyExpiryResponse{}), nil
|
||||
}
|
||||
|
||||
+16
-64
@@ -2,83 +2,35 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/jsiebens/ionscale/internal/auth"
|
||||
"github.com/jsiebens/ionscale/internal/broker"
|
||||
"github.com/jsiebens/ionscale/internal/config"
|
||||
"github.com/jsiebens/ionscale/internal/domain"
|
||||
"github.com/jsiebens/ionscale/internal/token"
|
||||
"github.com/jsiebens/ionscale/internal/version"
|
||||
"github.com/jsiebens/ionscale/pkg/gen/api"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
"strings"
|
||||
"tailscale.com/types/key"
|
||||
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
errMissingMetadata = status.Error(codes.InvalidArgument, "missing metadata")
|
||||
errInvalidToken = status.Error(codes.Unauthenticated, "invalid token")
|
||||
)
|
||||
|
||||
func NewService(repository domain.Repository, brokerPool *broker.BrokerPool) *Service {
|
||||
func NewService(config *config.Config, authProvider auth.Provider, repository domain.Repository, pubsub broker.Pubsub) *Service {
|
||||
return &Service{
|
||||
repository: repository,
|
||||
brokerPool: brokerPool,
|
||||
config: config,
|
||||
authProvider: authProvider,
|
||||
repository: repository,
|
||||
pubsub: pubsub,
|
||||
}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository domain.Repository
|
||||
brokerPool *broker.BrokerPool
|
||||
config *config.Config
|
||||
authProvider auth.Provider
|
||||
repository domain.Repository
|
||||
pubsub broker.Pubsub
|
||||
}
|
||||
|
||||
func (s *Service) brokers(tailnetID uint64) broker.Broker {
|
||||
return s.brokerPool.Get(tailnetID)
|
||||
}
|
||||
|
||||
func (s *Service) GetVersion(ctx context.Context, req *api.GetVersionRequest) (*api.GetVersionResponse, error) {
|
||||
func (s *Service) GetVersion(_ context.Context, _ *connect.Request[api.GetVersionRequest]) (*connect.Response[api.GetVersionResponse], error) {
|
||||
v, revision := version.GetReleaseInfo()
|
||||
return &api.GetVersionResponse{
|
||||
return connect.NewResponse(&api.GetVersionResponse{
|
||||
Version: v,
|
||||
Revision: revision,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func UnaryServerTokenAuth(systemAdminKey key.MachinePrivate) func(context.Context, interface{}, *grpc.UnaryServerInfo, grpc.UnaryHandler) (interface{}, error) {
|
||||
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
|
||||
if strings.HasSuffix(info.FullMethod, "/GetVersion") {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, errMissingMetadata
|
||||
}
|
||||
|
||||
// The keys within metadata.MD are normalized to lowercase.
|
||||
// See: https://godoc.org/google.golang.org/grpc/metadata#New
|
||||
valid := validateAuthorizationToken(systemAdminKey, md["authorization"])
|
||||
|
||||
if valid {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
return nil, errInvalidToken
|
||||
}
|
||||
}
|
||||
|
||||
func validateAuthorizationToken(systemAdminKey key.MachinePrivate, authorization []string) bool {
|
||||
if len(authorization) != 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
bearerToken := strings.TrimPrefix(authorization[0], "Bearer ")
|
||||
|
||||
if token.IsSystemAdminToken(bearerToken) {
|
||||
_, err := token.ParseSystemAdminToken(systemAdminKey, bearerToken)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
return false
|
||||
}), nil
|
||||
}
|
||||
|
||||
+362
-11
@@ -2,18 +2,40 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jsiebens/ionscale/pkg/gen/api"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/jsiebens/ionscale/internal/broker"
|
||||
"github.com/jsiebens/ionscale/internal/domain"
|
||||
"github.com/jsiebens/ionscale/internal/util"
|
||||
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func (s *Service) CreateTailnet(ctx context.Context, req *api.CreateTailnetRequest) (*api.CreateTailnetResponse, error) {
|
||||
tailnet, created, err := s.repository.GetOrCreateTailnet(ctx, req.Name)
|
||||
func (s *Service) CreateTailnet(ctx context.Context, req *connect.Request[api.CreateTailnetRequest]) (*connect.Response[api.CreateTailnetResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
name := req.Msg.Name
|
||||
iamPolicy := domain.IAMPolicy{}
|
||||
|
||||
if req.Msg.IamPolicy != nil {
|
||||
iamPolicy.Subs = req.Msg.IamPolicy.Subs
|
||||
iamPolicy.Emails = req.Msg.IamPolicy.Emails
|
||||
iamPolicy.Filters = req.Msg.IamPolicy.Filters
|
||||
iamPolicy.Roles = apiRolesMapToDomainRolesMap(req.Msg.IamPolicy.Roles)
|
||||
}
|
||||
|
||||
tailnet, created, err := s.repository.GetOrCreateTailnet(ctx, name, iamPolicy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !created {
|
||||
return nil, fmt.Errorf("tailnet already exists")
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("tailnet already exists"))
|
||||
}
|
||||
|
||||
resp := &api.CreateTailnetResponse{Tailnet: &api.Tailnet{
|
||||
@@ -21,19 +43,348 @@ func (s *Service) CreateTailnet(ctx context.Context, req *api.CreateTailnetReque
|
||||
Name: tailnet.Name,
|
||||
}}
|
||||
|
||||
return resp, nil
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *Service) ListTailnets(ctx context.Context, _ *api.ListTailnetRequest) (*api.ListTailnetResponse, error) {
|
||||
resp := &api.ListTailnetResponse{}
|
||||
func (s *Service) GetTailnet(ctx context.Context, req *connect.Request[api.GetTailnetRequest]) (*connect.Response[api.GetTailnetResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.Id) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnets, err := s.repository.ListTailnets(ctx)
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range tailnets {
|
||||
gt := api.Tailnet{Id: t.ID, Name: t.Name}
|
||||
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.GetTailnetResponse{Tailnet: &api.Tailnet{
|
||||
Id: tailnet.ID,
|
||||
Name: tailnet.Name,
|
||||
}}), nil
|
||||
}
|
||||
|
||||
func (s *Service) ListTailnets(ctx context.Context, req *connect.Request[api.ListTailnetRequest]) (*connect.Response[api.ListTailnetResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
|
||||
resp := &api.ListTailnetResponse{}
|
||||
|
||||
if principal.IsSystemAdmin() {
|
||||
tailnets, err := s.repository.ListTailnets(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range tailnets {
|
||||
gt := api.Tailnet{Id: t.ID, Name: t.Name}
|
||||
resp.Tailnet = append(resp.Tailnet, >)
|
||||
}
|
||||
}
|
||||
|
||||
if principal.User != nil {
|
||||
tailnet, err := s.repository.GetTailnet(ctx, principal.User.TailnetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gt := api.Tailnet{Id: tailnet.ID, Name: tailnet.Name}
|
||||
resp.Tailnet = append(resp.Tailnet, >)
|
||||
}
|
||||
return resp, nil
|
||||
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteTailnet(ctx context.Context, req *connect.Request[api.DeleteTailnetRequest]) (*connect.Response[api.DeleteTailnetResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
count, err := s.repository.CountMachineByTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !req.Msg.Force && count > 0 {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("tailnet is not empty, number of machines: %d", count))
|
||||
}
|
||||
|
||||
err = s.repository.Transaction(func(tx domain.Repository) error {
|
||||
if err := tx.DeleteMachineByTailnet(ctx, req.Msg.TailnetId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.DeleteApiKeysByTailnet(ctx, req.Msg.TailnetId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.DeleteAuthKeysByTailnet(ctx, req.Msg.TailnetId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.DeleteUsersByTailnet(ctx, req.Msg.TailnetId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.DeleteTailnet(ctx, req.Msg.TailnetId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(req.Msg.TailnetId, &broker.Signal{})
|
||||
|
||||
return connect.NewResponse(&api.DeleteTailnetResponse{}), nil
|
||||
}
|
||||
|
||||
func (s *Service) SetDERPMap(ctx context.Context, req *connect.Request[api.SetDERPMapRequest]) (*connect.Response[api.SetDERPMapResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
derpMap := tailcfg.DERPMap{}
|
||||
if err := json.Unmarshal(req.Msg.Value, &derpMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
tailnet.DERPMap = domain.DERPMap{
|
||||
Checksum: util.Checksum(&derpMap),
|
||||
DERPMap: derpMap,
|
||||
}
|
||||
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(tailnet.ID, &broker.Signal{})
|
||||
|
||||
raw, err := json.Marshal(derpMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.SetDERPMapResponse{Value: raw}), nil
|
||||
}
|
||||
|
||||
func (s *Service) ResetDERPMap(ctx context.Context, req *connect.Request[api.ResetDERPMapRequest]) (*connect.Response[api.ResetDERPMapResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
tailnet.DERPMap = domain.DERPMap{}
|
||||
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(tailnet.ID, &broker.Signal{})
|
||||
|
||||
return connect.NewResponse(&api.ResetDERPMapResponse{}), nil
|
||||
}
|
||||
|
||||
func (s *Service) GetDERPMap(ctx context.Context, req *connect.Request[api.GetDERPMapRequest]) (*connect.Response[api.GetDERPMapResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
derpMap, err := tailnet.GetDERPMap(ctx, s.repository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(derpMap.DERPMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.GetDERPMapResponse{Value: raw}), nil
|
||||
}
|
||||
|
||||
func (s *Service) EnabledFileSharing(ctx context.Context, req *connect.Request[api.EnableFileSharingRequest]) (*connect.Response[api.EnableFileSharingResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
if !tailnet.FileSharingEnabled {
|
||||
tailnet.FileSharingEnabled = true
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(tailnet.ID, &broker.Signal{})
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.EnableFileSharingResponse{}), nil
|
||||
}
|
||||
|
||||
func (s *Service) DisableFileSharing(ctx context.Context, req *connect.Request[api.DisableFileSharingRequest]) (*connect.Response[api.DisableFileSharingResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
if tailnet.FileSharingEnabled {
|
||||
tailnet.FileSharingEnabled = false
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(tailnet.ID, &broker.Signal{})
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.DisableFileSharingResponse{}), nil
|
||||
}
|
||||
|
||||
func (s *Service) EnabledServiceCollection(ctx context.Context, req *connect.Request[api.EnableServiceCollectionRequest]) (*connect.Response[api.EnableServiceCollectionResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
if !tailnet.ServiceCollectionEnabled {
|
||||
tailnet.ServiceCollectionEnabled = true
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(tailnet.ID, &broker.Signal{})
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.EnableServiceCollectionResponse{}), nil
|
||||
}
|
||||
|
||||
func (s *Service) DisableServiceCollection(ctx context.Context, req *connect.Request[api.DisableServiceCollectionRequest]) (*connect.Response[api.DisableServiceCollectionResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
if tailnet.ServiceCollectionEnabled {
|
||||
tailnet.ServiceCollectionEnabled = false
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(tailnet.ID, &broker.Signal{})
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.DisableServiceCollectionResponse{}), nil
|
||||
}
|
||||
|
||||
func (s *Service) EnabledSSH(ctx context.Context, req *connect.Request[api.EnableSSHRequest]) (*connect.Response[api.EnableSSHResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
if !tailnet.SSHEnabled {
|
||||
tailnet.SSHEnabled = true
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(tailnet.ID, &broker.Signal{})
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.EnableSSHResponse{}), nil
|
||||
}
|
||||
|
||||
func (s *Service) DisableSSH(ctx context.Context, req *connect.Request[api.DisableSSHRequest]) (*connect.Response[api.DisableSSHResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(req.Msg.TailnetId) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
if tailnet.SSHEnabled {
|
||||
tailnet.SSHEnabled = false
|
||||
if err := s.repository.SaveTailnet(ctx, tailnet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(tailnet.ID, &broker.Signal{})
|
||||
}
|
||||
|
||||
return connect.NewResponse(&api.DisableSSHResponse{}), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/jsiebens/ionscale/internal/broker"
|
||||
"github.com/jsiebens/ionscale/internal/domain"
|
||||
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
|
||||
)
|
||||
|
||||
func (s *Service) ListUsers(ctx context.Context, req *connect.Request[api.ListUsersRequest]) (*connect.Response[api.ListUsersResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
|
||||
tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tailnet == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("tailnet not found"))
|
||||
}
|
||||
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(tailnet.ID) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
users, err := s.repository.ListUsers(ctx, tailnet.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &api.ListUsersResponse{}
|
||||
for _, u := range users {
|
||||
resp.Users = append(resp.Users, &api.User{
|
||||
Id: u.ID,
|
||||
Name: u.Name,
|
||||
Role: string(tailnet.IAMPolicy.GetRole(u)),
|
||||
})
|
||||
}
|
||||
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteUser(ctx context.Context, req *connect.Request[api.DeleteUserRequest]) (*connect.Response[api.DeleteUserResponse], error) {
|
||||
principal := CurrentPrincipal(ctx)
|
||||
|
||||
if !principal.IsSystemAdmin() && principal.UserMatches(req.Msg.UserId) {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("unable delete yourself"))
|
||||
}
|
||||
|
||||
user, err := s.repository.GetUser(ctx, req.Msg.UserId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errors.New("user not found"))
|
||||
}
|
||||
|
||||
if !principal.IsSystemAdmin() && !principal.IsTailnetAdmin(user.TailnetID) {
|
||||
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
|
||||
}
|
||||
|
||||
err = s.repository.Transaction(func(tx domain.Repository) error {
|
||||
if err := tx.DeleteMachineByUser(ctx, req.Msg.UserId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.DeleteApiKeysByUser(ctx, req.Msg.UserId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.DeleteAuthKeysByUser(ctx, req.Msg.UserId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.DeleteUser(ctx, req.Msg.UserId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pubsub.Publish(user.TailnetID, &broker.Signal{})
|
||||
|
||||
return connect.NewResponse(&api.DeleteUserResponse{}), nil
|
||||
}
|
||||
@@ -74,11 +74,29 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
{{if .ProviderAvailable}}
|
||||
<div style="text-align: left; padding-bottom: 10px">
|
||||
<p><b>Authentication required</b></p>
|
||||
<small>Login with:</small>
|
||||
</div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{.Csrf}}">
|
||||
<ul class="selectionList">
|
||||
<li><button type="submit" name="s" value="true">OpenID</button></li>
|
||||
</ul>
|
||||
</form>
|
||||
<div style="text-align: left; padding-bottom: 10px; padding-top: 20px">
|
||||
<small>Or enter an <label for="ak">auth key</label> here:</small>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if not .ProviderAvailable}}
|
||||
<div style="text-align: left; padding-bottom: 10px">
|
||||
<p><b>Authentication required</b></p>
|
||||
<small>Enter an <label for="ak">auth key</label> here:</small>
|
||||
</div>
|
||||
{{end}}
|
||||
<form method="post" style="text-align: right">
|
||||
<input type="hidden" name="_csrf" value="{{.Csrf}}">
|
||||
<p><input id="ak" name="ak" type="text"/></p>
|
||||
<div style="padding-top: 10px">
|
||||
<button type="submit">submit</button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user