Compare commits

...

9 Commits

Author SHA1 Message Date
Johan Siebens cbbaa31580 fix: use stdencoding instead of rawstdencoding 2023-01-03 08:13:06 +01:00
Johan Siebens 35c46eb2ec chore: fix workflow file 2023-01-02 14:10:10 +01:00
Johan Siebens d6a564b7a9 chore: some initial integration tests 2023-01-02 14:07:38 +01:00
Johan Siebens 527fb34560 fix: use smallzstd and sync pool, slightly improving performance 2023-01-01 12:08:25 +01:00
Johan Siebens 805a516626 fix: avoid double user entries 2023-01-01 09:43:14 +01:00
Johan Siebens 0dbc81d50f fix: send exit node prefixes when enabled 2023-01-01 08:10:31 +01:00
Johan Siebens 40cc7b5648 fix: send same user for all tagged devices, reducing mapresponse payload when having many tagged devices 2022-12-31 08:26:30 +01:00
Johan Siebens b62db084d1 feat: add config to tweak sql connection pool 2022-12-30 09:28:50 +01:00
Johan Siebens df23c178f9 feat: add gorm prometheus metrics 2022-12-30 09:07:29 +01:00
17 changed files with 1245 additions and 73 deletions
+1 -1
View File
@@ -32,5 +32,5 @@ jobs:
go-version: 1.19
- name: Build
run: |
go test ./...
go test -v -short ./...
go build cmd/ionscale/main.go
+32
View File
@@ -0,0 +1,32 @@
name: Integration Tests
on: workflow_dispatch
jobs:
integration:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ts_version:
- "1.34.1"
- "1.32.3"
- "1.30.2"
- "1.28.0"
- "1.26.2"
- "1.24.2"
- "1.22.2"
env:
IONSCALE_TESTS_TS_TARGET_VERSION: ${{ matrix.ts_version }}
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.19
- name: Run Integration Tests
run: |
go test -v ./tests
+21 -1
View File
@@ -45,6 +45,7 @@ require (
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.4.4
gorm.io/gorm v1.24.0
gorm.io/plugin/prometheus v0.0.0-20221204031128-799a96c40bf9
inet.af/netaddr v0.0.0-20220811202034-502d2d690317
tailscale.com v1.32.0
)
@@ -62,6 +63,8 @@ require (
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/Microsoft/go-winio v0.5.2 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // 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
@@ -76,18 +79,26 @@ require (
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/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/containerd/continuity v0.3.0 // 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/docker/cli v20.10.16+incompatible // indirect
github.com/docker/docker v20.10.16+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/form3tech-oss/jwt-go v3.2.2+incompatible // indirect
github.com/glebarez/go-sqlite v1.19.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa // indirect
github.com/googleapis/gax-go/v2 v2.4.0 // indirect
@@ -111,22 +122,31 @@ require (
github.com/lib/pq v1.10.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2 // indirect
github.com/mdlayher/netlink v1.6.0 // indirect
github.com/mdlayher/socket v0.2.3 // indirect
github.com/mholt/acmez v1.0.4 // indirect
github.com/miekg/dns v1.1.50 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.3-0.20220114050600-8b9d41f48198 // indirect
github.com/opencontainers/runc v1.1.2 // indirect
github.com/ory/dockertest/v3 v3.9.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // 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
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
go.opencensus.io v0.23.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
+570 -1
View File
File diff suppressed because it is too large Load Diff
+22 -12
View File
@@ -8,6 +8,8 @@ import (
"github.com/klauspost/compress/zstd"
"github.com/labstack/echo/v4"
"io/ioutil"
"sync"
"tailscale.com/smallzstd"
"tailscale.com/types/key"
)
@@ -77,12 +79,7 @@ func (d *defaultBinder) Marshal(compress string, v interface{}) ([]byte, error)
}
if compress == "zstd" {
encoder, err := zstd.NewWriter(nil)
if err != nil {
return nil, err
}
payload = encoder.EncodeAll(marshalled, nil)
payload = zstdEncode(marshalled)
} else {
payload = marshalled
}
@@ -140,12 +137,7 @@ func (b *boxBinder) Marshal(compress string, v interface{}) ([]byte, error) {
}
if compress == "zstd" {
encoder, err := zstd.NewWriter(nil)
if err != nil {
return nil, err
}
encoded := encoder.EncodeAll(marshalled, nil)
encoded := zstdEncode(marshalled)
payload = b.controlKey.SealTo(b.machineKey, encoded)
} else {
payload = b.controlKey.SealTo(b.machineKey, marshalled)
@@ -161,3 +153,21 @@ func (b *boxBinder) Marshal(compress string, v interface{}) ([]byte, error) {
func (b *boxBinder) Peer() key.MachinePublic {
return b.machineKey
}
func zstdEncode(in []byte) []byte {
encoder := zstdEncoderPool.Get().(*zstd.Encoder)
out := encoder.EncodeAll(in, nil)
encoder.Close()
zstdEncoderPool.Put(encoder)
return out
}
var zstdEncoderPool = &sync.Pool{
New: func() any {
encoder, err := smallzstd.NewEncoder(nil, zstd.WithEncoderLevel(zstd.SpeedFastest))
if err != nil {
panic(err)
}
return encoder
},
}
+11 -5
View File
@@ -67,7 +67,7 @@ func LoadConfig(path string) (*Config, error) {
envCfgB64 := os.Getenv("IONSCALE_CONFIG_BASE64")
if len(envCfgB64) != 0 {
b, err := base64.RawStdEncoding.DecodeString(envCfgB64)
b, err := base64.StdEncoding.DecodeString(envCfgB64)
if err != nil {
return nil, err
}
@@ -105,8 +105,10 @@ func defaultConfig() *Config {
MetricsListenAddr: ":9091",
ServerUrl: "https://localhost:8843",
Database: Database{
Type: "sqlite",
Url: "./ionscale.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)",
Type: "sqlite",
Url: "./ionscale.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)",
MaxOpenConns: 0,
MaxIdleConns: 2,
},
Tls: Tls{
Disable: false,
@@ -169,8 +171,12 @@ type Logging struct {
}
type Database struct {
Type string `yaml:"type,omitempty" env:"TYPE"`
Url string `yaml:"url,omitempty" env:"URL"`
Type string `yaml:"type,omitempty" env:"TYPE"`
Url string `yaml:"url,omitempty" env:"URL"`
MaxOpenConns int `yaml:"max_open_conns,omitempty" env:"MAX_OPEN_CONNS"`
MaxIdleConns int `yaml:"max_idle_conns,omitempty" env:"MAX_IDLE_CONNS"`
ConnMaxLifetime time.Duration `yaml:"conn_max_life_time,omitempty" env:"CONN_MAX_LIFE_TIME"`
ConnMaxIdleTime time.Duration `yaml:"conn_max_idle_time,omitempty" env:"CONN_MAX_IDLE_TIME"`
}
type Keys struct {
+20 -9
View File
@@ -15,35 +15,46 @@ import (
"github.com/jsiebens/ionscale/internal/domain"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/plugin/prometheus"
)
type db interface {
DB() *gorm.DB
type dbLock interface {
Lock() error
Unlock() error
UnlockErr(error) error
}
func OpenDB(config *config.Database, logger hclog.Logger) (domain.Repository, error) {
db, err := createDB(config, logger)
db, lock, err := createDB(config, logger)
if err != nil {
return nil, err
}
repository := domain.NewRepository(db.DB())
_ = db.Use(prometheus.New(prometheus.Config{StartServer: false}))
if err := db.Lock(); err != nil {
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
if err := db.UnlockErr(migrate(db.DB())); err != nil {
sqlDB.SetMaxOpenConns(config.MaxOpenConns)
sqlDB.SetMaxIdleConns(config.MaxIdleConns)
sqlDB.SetConnMaxLifetime(config.ConnMaxLifetime)
sqlDB.SetConnMaxIdleTime(config.ConnMaxIdleTime)
repository := domain.NewRepository(db)
if err := lock.Lock(); err != nil {
return nil, err
}
if err := lock.UnlockErr(migrate(db)); err != nil {
return nil, err
}
return repository, nil
}
func createDB(config *config.Database, logger hclog.Logger) (db, error) {
func createDB(config *config.Database, logger hclog.Logger) (*gorm.DB, dbLock, error) {
gormConfig := &gorm.Config{
Logger: &GormLoggerAdapter{logger: logger.Named("db")},
}
@@ -55,7 +66,7 @@ func createDB(config *config.Database, logger hclog.Logger) (db, error) {
return newPostgresDB(config, gormConfig)
}
return nil, fmt.Errorf("invalid database type '%s'", config.Type)
return nil, nil, fmt.Errorf("invalid database type '%s'", config.Type)
}
func migrate(db *gorm.DB) error {
+14 -20
View File
@@ -11,26 +11,20 @@ import (
"gorm.io/gorm"
)
func newPostgresDB(config *config.Database, g *gorm.Config) (db, error) {
func newPostgresDB(config *config.Database, g *gorm.Config) (*gorm.DB, dbLock, error) {
db, err := gorm.Open(postgres.Open(config.Url), g)
if err != nil {
return nil, err
return nil, nil, err
}
return &Postgres{
db: db,
}, nil
return db, &pgLock{db: db}, nil
}
type Postgres struct {
type pgLock struct {
db *gorm.DB
}
func (s *Postgres) DB() *gorm.DB {
return s.db
}
func (s *Postgres) Lock() error {
func (s *pgLock) Lock() error {
d, _ := s.db.DB()
query := `SELECT pg_advisory_lock($1)`
@@ -42,7 +36,14 @@ func (s *Postgres) Lock() error {
return nil
}
func (s *Postgres) Unlock() error {
func (s *pgLock) UnlockErr(prevErr error) error {
if err := s.unlock(); err != nil {
return multierror.Append(prevErr, err)
}
return prevErr
}
func (s *pgLock) unlock() error {
d, _ := s.db.DB()
query := `SELECT pg_advisory_unlock($1)`
@@ -53,16 +54,9 @@ func (s *Postgres) Unlock() error {
return nil
}
func (s *Postgres) UnlockErr(prevErr error) error {
if err := s.Unlock(); err != nil {
return multierror.Append(prevErr, err)
}
return prevErr
}
const advisoryLockIDSalt uint = 1486364155
func (s *Postgres) generateAdvisoryLockId() string {
func (s *pgLock) generateAdvisoryLockId() string {
sum := crc32.ChecksumIEEE([]byte("ionscale_migration"))
sum = sum * uint32(advisoryLockIDSalt)
return fmt.Sprint(sum)
+6 -18
View File
@@ -6,33 +6,21 @@ import (
"gorm.io/gorm"
)
func newSqliteDB(config *config.Database, g *gorm.Config) (db, error) {
func newSqliteDB(config *config.Database, g *gorm.Config) (*gorm.DB, dbLock, error) {
db, err := gorm.Open(sqlite.Open(config.Url), g)
if err != nil {
return nil, err
return nil, nil, err
}
return &Sqlite{
db: db,
}, nil
return db, &sqliteLock{}, nil
}
type Sqlite struct {
db *gorm.DB
type sqliteLock struct {
}
func (s *Sqlite) DB() *gorm.DB {
return s.db
}
func (s *Sqlite) Lock() error {
func (s *sqliteLock) Lock() error {
return nil
}
func (s *Sqlite) Unlock() error {
return nil
}
func (s *Sqlite) UnlockErr(prevErr error) error {
func (s *sqliteLock) UnlockErr(prevErr error) error {
return prevErr
}
+8 -3
View File
@@ -214,8 +214,13 @@ func (h *PollNetMapHandler) createMapResponse(m *domain.Machine, binder bind.Bin
return nil, nil, "", err
}
serviceUser, _, err := h.repository.GetOrCreateServiceUser(ctx, tailnet)
if err != nil {
return nil, nil, "", err
}
hostinfo := tailcfg.Hostinfo(m.HostInfo)
node, user, err := mapping.ToNode(m, tailnet, false, true, prc.filter)
node, user, err := mapping.ToNode(m, tailnet, serviceUser, false, true, prc.filter)
if err != nil {
return nil, nil, "", err
}
@@ -231,7 +236,7 @@ func (h *PollNetMapHandler) createMapResponse(m *domain.Machine, binder bind.Bin
}
syncedPeerIDs := map[uint64]bool{}
syncedUserIDs := map[tailcfg.UserID]bool{}
syncedUserIDs := map[tailcfg.UserID]bool{user.ID: true}
for _, peer := range candidatePeers {
if peer.IsExpired() {
@@ -240,7 +245,7 @@ func (h *PollNetMapHandler) createMapResponse(m *domain.Machine, binder bind.Bin
if policies.IsValidPeer(m, &peer) || policies.IsValidPeer(&peer, m) {
isConnected := h.sessionManager.HasSession(peer.TailnetID, peer.ID)
n, u, err := mapping.ToNode(&peer, tailnet, true, isConnected, prc.filter)
n, u, err := mapping.ToNode(&peer, tailnet, serviceUser, true, isConnected, prc.filter)
if err != nil {
return nil, nil, "", err
}
+7 -3
View File
@@ -82,7 +82,7 @@ func ToDNSConfig(m *domain.Machine, tailnet *domain.Tailnet, c *domain.DNSConfig
return dnsConfig
}
func ToNode(m *domain.Machine, tailnet *domain.Tailnet, peer bool, connected bool, routeFilter func(m *domain.Machine) []netip.Prefix) (*tailcfg.Node, *tailcfg.UserProfile, error) {
func ToNode(m *domain.Machine, tailnet *domain.Tailnet, taggedDevicesUser *domain.User, peer bool, connected bool, routeFilter func(m *domain.Machine) []netip.Prefix) (*tailcfg.Node, *tailcfg.UserProfile, error) {
role := tailnet.IAMPolicy.GetRole(m.User)
var capabilities []string
@@ -148,6 +148,10 @@ func ToNode(m *domain.Machine, tailnet *domain.Tailnet, peer bool, connected boo
allowedIPs = append(allowedIPs, routeFilter(m)...)
}
if m.IsAllowedExitNode() {
allowedIPs = append(allowedIPs, netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"))
}
var derp string
if hostinfo.NetInfo != nil {
derp = fmt.Sprintf("127.3.3.40:%d", hostinfo.NetInfo.PreferredDERP)
@@ -203,9 +207,9 @@ func ToNode(m *domain.Machine, tailnet *domain.Tailnet, peer bool, connected boo
var user = ToUserProfile(m.User)
if m.HasTags() {
n.User = tailcfg.UserID(m.ID)
n.User = tailcfg.UserID(taggedDevicesUser.ID)
user = tailcfg.UserProfile{
ID: tailcfg.UserID(m.ID),
ID: tailcfg.UserID(taggedDevicesUser.ID),
LoginName: "tagged-devices",
DisplayName: "Tagged Devices",
}
+16
View File
@@ -0,0 +1,16 @@
http_listen_addr: ":8080"
server_url: "http://localhost:8080"
tls:
disable: true
force_https: false
keys:
system_admin_key: "804ecd57365342254ce6647da5c249e85c10a0e51e74856bfdf292a2136b4249"
database:
type: sqlite
url: /opt/ionscale.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)
logging:
level: trace
+19
View File
@@ -0,0 +1,19 @@
FROM golang:1.19-buster as builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . ./
RUN go build -v -o ionscale cmd/ionscale/main.go
FROM debian:buster-slim
RUN set -x && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/ionscale /usr/local/bin/ionscale
ENTRYPOINT ["/usr/local/bin/ionscale"]
+11
View File
@@ -0,0 +1,11 @@
FROM alpine:3.14.0
ARG TAILSCALE_VERSION
RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/*
WORKDIR /app
ENV TSFILE=tailscale_${TAILSCALE_VERSION}_amd64.tgz
RUN wget https://pkgs.tailscale.com/stable/${TSFILE} && tar xzf ${TSFILE} --strip-components=1
RUN mkdir -p /var/run/tailscale /var/cache/tailscale /var/lib/tailscale /.cache
+80
View File
@@ -0,0 +1,80 @@
package tests
import (
"github.com/jsiebens/ionscale/tests/sc"
"github.com/stretchr/testify/assert"
"testing"
)
func TestPing(t *testing.T) {
sc.Run(t, func(s sc.Scenario) {
tailnet := s.CreateTailnet("pingtest")
key := s.CreateAuthKey(tailnet.Id, true)
nodeA := s.NewTailscaleNode("pingtest-a")
nodeB := s.NewTailscaleNode("pingtest-b")
nodeA.Up(key)
nodeB.Up(key)
nodeA.WaitForPeers(1)
nodeA.Ping("pingtest-b")
nodeA.Ping(nodeB.IPv4())
nodeA.Ping(nodeB.IPv6())
})
}
func TestGetIPs(t *testing.T) {
sc.Run(t, func(s sc.Scenario) {
tailnet := s.CreateTailnet("tailnet01")
authKey := s.CreateAuthKey(tailnet.Id, false)
tsNode := s.NewTailscaleNode("testip")
tsNode.Up(authKey)
ip4 := tsNode.IPv4()
ip6 := tsNode.IPv6()
var found = false
machines := s.ListMachines(tailnet.Id)
for _, m := range machines {
if m.Name == tsNode.Hostname() {
found = true
assert.Equal(t, m.Ipv4, ip4)
assert.Equal(t, m.Ipv6, ip6)
}
}
assert.True(t, found)
})
}
func TestNodeWithSameHostname(t *testing.T) {
sc.Run(t, func(s sc.Scenario) {
tailnet := s.CreateTailnet("tailnet01")
authKey := s.CreateAuthKey(tailnet.Id, false)
tsNode := s.NewTailscaleNode("test")
_ = tsNode.Up(authKey)
for i := 0; i < 5; i++ {
tc := s.NewTailscaleNode("test")
_ = tc.Up(authKey)
}
machines := make(map[string]bool)
for _, m := range s.ListMachines(tailnet.Id) {
machines[m.Name] = true
}
assert.Equal(t, map[string]bool{
"test": true,
"test-1": true,
"test-2": true,
"test-3": true,
"test-4": true,
"test-5": true,
}, machines)
})
}
+136
View File
@@ -0,0 +1,136 @@
package sc
import (
"bytes"
"encoding/json"
"fmt"
"github.com/ory/dockertest/v3"
"strings"
"tailscale.com/ipn/ipnstate"
"testing"
)
type TailscaleNode interface {
Hostname() string
Up(authkey string) ipnstate.Status
IPv4() string
IPv6() string
WaitForPeers(expected int)
Ping(target string)
}
type tailscaleNode struct {
t *testing.T
loginServer string
hostname string
resource *dockertest.Resource
}
func (t *tailscaleNode) Hostname() string {
return t.hostname
}
func (t *tailscaleNode) Up(authkey string) ipnstate.Status {
t.mustExecTailscaleCmd("up", "--login-server", t.loginServer, "--authkey", authkey)
return t.waitForReady()
}
func (t *tailscaleNode) IPv4() string {
return t.mustExecTailscaleCmd("ip", "-4")
}
func (t *tailscaleNode) IPv6() string {
return t.mustExecTailscaleCmd("ip", "-6")
}
func (t *tailscaleNode) waitForReady() ipnstate.Status {
var status ipnstate.Status
err := pool.Retry(func() error {
out, err := t.execTailscaleCmd("status", "--json")
if err != nil {
return err
}
if err := json.Unmarshal([]byte(out), &status); err != nil {
return err
}
if status.CurrentTailnet != nil {
return nil
}
return fmt.Errorf("not connected")
})
if err != nil {
t.t.Fatal(err)
}
return status
}
func (t *tailscaleNode) WaitForPeers(expected int) {
err := pool.Retry(func() error {
out, err := t.execTailscaleCmd("status", "--json")
if err != nil {
return err
}
var status ipnstate.Status
if err := json.Unmarshal([]byte(out), &status); err != nil {
return err
}
if len(status.Peers()) != expected {
return fmt.Errorf("incorrect peer count")
}
return nil
})
if err != nil {
t.t.Fatal(err)
}
}
func (t *tailscaleNode) Ping(target string) {
result, err := t.execTailscaleCmd("ping", "--timeout=1s", "--c=10", "--until-direct=true", target)
if err != nil {
t.t.Fatal(err)
}
if !strings.Contains(result, "pong") && !strings.Contains(result, "is local") {
t.t.Fatal("ping failed")
}
}
func (t *tailscaleNode) execTailscaleCmd(cmd ...string) (string, error) {
i := append([]string{"/app/tailscale", "--socket=/tmp/tailscaled.sock"}, cmd...)
return execCmd(t.resource, i...)
}
func (t *tailscaleNode) mustExecTailscaleCmd(cmd ...string) string {
i := append([]string{"/app/tailscale", "--socket=/tmp/tailscaled.sock"}, cmd...)
s, err := execCmd(t.resource, i...)
if err != nil {
t.t.Fatal(err)
}
return s
}
func execCmd(resource *dockertest.Resource, cmd ...string) (string, error) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode, err := resource.Exec(cmd, dockertest.ExecOptions{StdOut: &stdout, StdErr: &stderr})
if err != nil {
return "", err
}
if err != nil {
return strings.TrimSpace(stdout.String()), err
}
if exitCode != 0 {
return strings.TrimSpace(stdout.String()), fmt.Errorf("command failed with: %s", stderr.String())
}
return strings.TrimSpace(stdout.String()), nil
}
+271
View File
@@ -0,0 +1,271 @@
package sc
import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
ionscaleclt "github.com/jsiebens/ionscale/pkg/client/ionscale"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
ionscaleconnect "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1/ionscalev1connect"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"google.golang.org/protobuf/types/known/durationpb"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"testing"
"time"
)
const DefaultTargetVersion = "1.34.1"
var (
setupOnce sync.Once
targetVersion string
pool *dockertest.Pool
)
type Scenario interface {
NewTailscaleNode(hostname string) TailscaleNode
ListMachines(tailnetID uint64) []*api.Machine
CreateAuthKey(tailnetID uint64, ephemeral bool) string
CreateTailnet(name string) *api.Tailnet
}
type scenario struct {
t *testing.T
pool *dockertest.Pool
network *dockertest.Network
ionscale *dockertest.Resource
resources []*dockertest.Resource
client ionscaleconnect.IonscaleServiceClient
}
func (s *scenario) CreateTailnet(name string) *api.Tailnet {
createTailnetResponse, err := s.client.CreateTailnet(context.Background(), connect.NewRequest(&api.CreateTailnetRequest{Name: name}))
if err != nil {
s.t.Fatal(err)
}
return createTailnetResponse.Msg.GetTailnet()
}
func (s *scenario) CreateAuthKey(tailnetID uint64, ephemeral bool) string {
key, err := s.client.CreateAuthKey(context.Background(), connect.NewRequest(&api.CreateAuthKeyRequest{TailnetId: tailnetID, Ephemeral: ephemeral, Tags: []string{"tag:test"}, Expiry: durationpb.New(60 * time.Minute)}))
if err != nil {
s.t.Fatal(err)
}
return key.Msg.Value
}
func (s *scenario) ListMachines(tailnetID uint64) []*api.Machine {
machines, err := s.client.ListMachines(context.Background(), connect.NewRequest(&api.ListMachinesRequest{TailnetId: tailnetID}))
if err != nil {
s.t.Fatal(err)
}
return machines.Msg.Machines
}
func (s *scenario) NewTailscaleNode(hostname string) TailscaleNode {
tailscaleOptions := &dockertest.RunOptions{
Repository: fmt.Sprintf("ts-%s", strings.Replace(targetVersion, ".", "-", -1)),
Hostname: hostname,
Networks: []*dockertest.Network{s.network},
ExposedPorts: []string{"1055"},
Cmd: []string{
"/app/tailscaled", "--tun", "userspace-networking", "--socks5-server", "0.0.0.0:1055", "--socket", "/tmp/tailscaled.sock",
},
}
resource, err := s.pool.RunWithOptions(
tailscaleOptions,
restartPolicy,
)
if err != nil {
s.t.Fatal(err)
}
err = s.pool.Retry(portCheck(resource.GetPort("1055/tcp")))
if err != nil {
s.t.Fatal(err)
}
s.resources = append(s.resources, resource)
return &tailscaleNode{
t: s.t,
loginServer: "http://ionscale:8080",
hostname: hostname,
resource: resource,
}
}
func Run(t *testing.T, f func(s Scenario)) {
t.Parallel()
if testing.Short() {
t.Skip("skipped due to -short flag")
}
setupOnce.Do(prepareDockerPoolAndImages)
if pool == nil {
t.FailNow()
}
var err error
s := &scenario{t: t}
defer func() {
for _, r := range s.resources {
_ = pool.Purge(r)
}
if s.ionscale != nil {
_ = pool.Purge(s.ionscale)
}
if s.network != nil {
_ = s.network.Close()
}
s.resources = nil
s.network = nil
}()
if s.pool, err = dockertest.NewPool(""); err != nil {
t.Fatal(err)
}
s.network, err = pool.CreateNetwork("ionscale-test")
if err != nil {
t.Fatal(err)
}
currentPath, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
ionscale := &dockertest.RunOptions{
Hostname: "ionscale",
Repository: "ionscale-test",
Mounts: []string{
fmt.Sprintf("%s/config:/etc/ionscale", currentPath),
},
Networks: []*dockertest.Network{s.network},
ExposedPorts: []string{"8080"},
Cmd: []string{"server", "--config", "/etc/ionscale/config.yaml"},
}
s.ionscale, err = pool.RunWithOptions(ionscale, restartPolicy)
if err != nil {
t.Fatal(err)
}
port := s.ionscale.GetPort("8080/tcp")
err = pool.Retry(httpCheck(port, "/key"))
if err != nil {
t.Fatal(err)
}
auth, err := ionscaleclt.LoadClientAuth("804ecd57365342254ce6647da5c249e85c10a0e51e74856bfdf292a2136b4249")
if err != nil {
t.Fatal(err)
}
s.client, err = ionscaleclt.NewClient(auth, fmt.Sprintf("http://localhost:%s", port), true)
if err != nil {
t.Fatal(err)
}
f(s)
}
func restartPolicy(config *docker.HostConfig) {
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{
Name: "no",
}
}
func portCheck(port string) func() error {
return func() error {
conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%s", port))
if err != nil {
return err
}
defer conn.Close()
return nil
}
}
func httpCheck(port string, path string) func() error {
return func() error {
url := fmt.Sprintf("http://localhost:%s%s", port, path)
resp, err := http.Get(url)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status code not OK")
}
return nil
}
}
func prepareDockerPoolAndImages() {
targetVersion = os.Getenv("IONSCALE_TESTS_TS_TARGET_VERSION")
if targetVersion == "" {
targetVersion = DefaultTargetVersion
}
pool, _ = dockertest.NewPool("")
buildOpts := &dockertest.BuildOptions{
ContextDir: "./docker/tailscale",
BuildArgs: []docker.BuildArg{
{
Name: "TAILSCALE_VERSION",
Value: targetVersion,
},
},
}
err := pool.Client.BuildImage(docker.BuildImageOptions{
Name: fmt.Sprintf("ts-%s", strings.Replace(targetVersion, ".", "-", -1)),
Dockerfile: buildOpts.Dockerfile,
OutputStream: io.Discard,
ContextDir: buildOpts.ContextDir,
BuildArgs: buildOpts.BuildArgs,
Platform: buildOpts.Platform,
})
if err != nil {
log.Fatal(err)
}
buildOpts = &dockertest.BuildOptions{
ContextDir: "../",
Dockerfile: "tests/docker/ionscale/Dockerfile",
}
err = pool.Client.BuildImage(docker.BuildImageOptions{
Name: "ionscale-test",
Dockerfile: buildOpts.Dockerfile,
OutputStream: io.Discard,
ContextDir: buildOpts.ContextDir,
BuildArgs: buildOpts.BuildArgs,
Platform: buildOpts.Platform,
})
if err != nil {
log.Fatal(err)
}
}