chore: restructure test setup and add some initial web login flow tests

This commit is contained in:
Johan Siebens
2024-01-10 07:21:18 +01:00
parent 3118d2e573
commit 4587ed8eaa
16 changed files with 784 additions and 365 deletions
+5
View File
@@ -2,12 +2,15 @@ module github.com/jsiebens/ionscale
go 1.21
replace github.com/oauth2-proxy/mockoidc => github.com/jsiebens/mockoidc v0.0.0-20240108082145-5b2747b94b1b
require (
github.com/apparentlymart/go-cidr v1.1.0
github.com/bufbuild/connect-go v1.10.0
github.com/caarlos0/env/v6 v6.10.1
github.com/caddyserver/certmagic v0.20.0
github.com/coreos/go-oidc/v3 v3.9.0
github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2
github.com/glebarez/sqlite v1.10.0
github.com/go-gormigrate/gormigrate/v2 v2.0.2
github.com/go-jose/go-jose/v3 v3.0.1
@@ -29,6 +32,7 @@ require (
github.com/mitchellh/pointerstructure v1.2.1
github.com/mr-tron/base58 v1.2.0
github.com/nleeper/goment v1.4.4
github.com/oauth2-proxy/mockoidc v0.0.0-20220308204021-b9169deeb282
github.com/ory/dockertest/v3 v3.9.1
github.com/prometheus/client_golang v1.17.0
github.com/rodaine/table v1.1.0
@@ -168,6 +172,7 @@ require (
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0 // indirect
google.golang.org/grpc v1.60.1 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gotest.tools/v3 v3.4.0 // indirect
modernc.org/libc v1.38.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
+9
View File
@@ -329,6 +329,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2 h1:S6Dco8FtAhEI/qkg/00H6RdEGC+MCy5GPiQ+xweNRFE=
github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -558,6 +560,8 @@ github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMt
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/jsiebens/go-edit v0.1.0 h1:62SSGW8Qc2zoBcJx7gV86ImPHmQzlU/DQhwCOR4uilE=
github.com/jsiebens/go-edit v0.1.0/go.mod h1:m/wuWMv8sNhSl+M2qA35gP/K5jX2J7Aa+g16VwyfxrI=
github.com/jsiebens/mockoidc v0.0.0-20240108082145-5b2747b94b1b h1:Y/oA8TYORWGvOq5Ynl9tY+eA6IEV/5tjb3ugSU2Khfo=
github.com/jsiebens/mockoidc v0.0.0-20240108082145-5b2747b94b1b/go.mod h1:5AdBTMDkanoW4IMv51fajiAUEEmKyNza7s8R2D/5pbw=
github.com/jsimonetti/rtnetlink v1.3.5 h1:hVlNQNRlLDGZz31gBPicsG7Q53rnlsz1l1Ix/9XlpVA=
github.com/jsimonetti/rtnetlink v1.3.5/go.mod h1:0LFedyiTkebnd43tE4YAkWGIq9jQphow4CcwxaT2Y00=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
@@ -661,6 +665,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nleeper/goment v1.4.4 h1:GlMTpxvhueljArSunzYjN9Ri4SOmpn0Vh2hg2z/IIl8=
github.com/nleeper/goment v1.4.4/go.mod h1:zDl5bAyDhqxwQKAvkSXMRLOdCowrdZz53ofRJc4VhTo=
github.com/oauth2-proxy/mockoidc v0.0.0-20220308204021-b9169deeb282 h1:TQMyrpijtkFyXpNI3rY5hsZQZw+paiH+BfAlsb81HBY=
github.com/oauth2-proxy/mockoidc v0.0.0-20220308204021-b9169deeb282/go.mod h1:rW25Kyd08Wdn3UVn0YBsDTSvReu0jqpmJKzxITPSjks=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI=
@@ -843,6 +849,7 @@ golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
@@ -1406,6 +1413,8 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+112 -70
View File
@@ -45,6 +45,21 @@ type AuthFormData struct {
Csrf string
}
type AuthInput struct {
Key string `param:"key"`
Flow AuthFlow `param:"flow"`
AuthKey string `query:"ak" form:"ak"`
Oidc bool `query:"oidc" form:"oidc"`
}
type EndAuthForm struct {
AccountID uint64 `form:"aid"`
TailnetID uint64 `form:"tid"`
AsSystemAdmin bool `form:"sad"`
AuthKey string `form:"ak"`
State string `form:"state"`
}
type TailnetSelectionData struct {
AccountID uint64
Tailnets []domain.Tailnet
@@ -54,34 +69,54 @@ type TailnetSelectionData struct {
type oauthState struct {
Key string
Flow string
Flow AuthFlow
}
type AuthFlow string
const (
AuthFlowMachineRegistration = "r"
AuthFlowClient = "c"
AuthFlowSSHCheckFlow = "s"
)
func (h *AuthenticationHandlers) StartAuth(c echo.Context) error {
ctx := c.Request().Context()
flow := c.Param("flow")
key := c.Param("key")
var input AuthInput
if err := c.Bind(&input); err != nil {
return logError(err)
}
// machine registration auth flow
if flow == "r" || flow == "" {
if req, err := h.repository.GetRegistrationRequestByKey(ctx, key); err != nil || req == nil {
if input.Flow == AuthFlowMachineRegistration {
req, err := h.repository.GetRegistrationRequestByKey(ctx, input.Key)
if err != nil || req == nil {
return logError(err)
}
if input.Oidc && h.authProvider != nil {
goto startOidc
}
if input.AuthKey != "" {
return h.endMachineRegistrationFlow(c, EndAuthForm{AuthKey: input.AuthKey}, req)
}
csrf := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
return c.Render(http.StatusOK, "auth.html", &AuthFormData{ProviderAvailable: h.authProvider != nil, Csrf: csrf})
}
// cli auth flow
if flow == "c" {
if s, err := h.repository.GetAuthenticationRequest(ctx, key); err != nil || s == nil {
if input.Flow == AuthFlowClient {
if s, err := h.repository.GetAuthenticationRequest(ctx, input.Key); err != nil || s == nil {
return logError(err)
}
}
// ssh check auth flow
if flow == "s" {
if s, err := h.repository.GetSSHActionRequest(ctx, key); err != nil || s == nil {
if input.Flow == AuthFlowSSHCheckFlow {
if s, err := h.repository.GetSSHActionRequest(ctx, input.Key); err != nil || s == nil {
return logError(err)
}
}
@@ -90,7 +125,9 @@ func (h *AuthenticationHandlers) StartAuth(c echo.Context) error {
return logError(fmt.Errorf("unable to start auth flow as no auth provider is configured"))
}
state, err := h.createState(flow, key)
startOidc:
state, err := h.createState(input.Flow, input.Key)
if err != nil {
return logError(err)
}
@@ -103,21 +140,22 @@ func (h *AuthenticationHandlers) StartAuth(c echo.Context) error {
func (h *AuthenticationHandlers) ProcessAuth(c echo.Context) error {
ctx := c.Request().Context()
key := c.Param("key")
authKey := c.FormValue("ak")
interactive := c.FormValue("s")
var input AuthInput
if err := c.Bind(&input); err != nil {
return logError(err)
}
req, err := h.repository.GetRegistrationRequestByKey(ctx, key)
req, err := h.repository.GetRegistrationRequestByKey(ctx, input.Key)
if err != nil || req == nil {
return logError(err)
}
if authKey != "" {
return h.endMachineRegistrationFlow(c, req, &oauthState{Key: key})
if input.AuthKey != "" {
return h.endMachineRegistrationFlow(c, EndAuthForm{AuthKey: input.AuthKey}, req)
}
if interactive != "" {
state, err := h.createState("r", key)
if input.Oidc {
state, err := h.createState(input.Flow, input.Key)
if err != nil {
return logError(err)
}
@@ -127,7 +165,7 @@ func (h *AuthenticationHandlers) ProcessAuth(c echo.Context) error {
return c.Redirect(http.StatusFound, redirectUrl)
}
return c.Redirect(http.StatusFound, "/a/"+key)
return c.Redirect(http.StatusFound, fmt.Sprintf("/a/%s/%s", input.Flow, input.Key))
}
func (h *AuthenticationHandlers) Callback(c echo.Context) error {
@@ -153,7 +191,7 @@ func (h *AuthenticationHandlers) Callback(c echo.Context) error {
return logError(err)
}
if state.Flow == "s" {
if state.Flow == AuthFlowSSHCheckFlow {
sshActionReq, err := h.repository.GetSSHActionRequest(ctx, state.Key)
if err != nil || sshActionReq == nil {
return c.Redirect(http.StatusFound, "/a/error?e=ua")
@@ -186,7 +224,7 @@ func (h *AuthenticationHandlers) Callback(c echo.Context) error {
csrf := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
if state.Flow == "r" {
if state.Flow == AuthFlowMachineRegistration {
if len(tailnets) == 0 {
registrationRequest, err := h.repository.GetRegistrationRequestByKey(ctx, state.Key)
if err == nil && registrationRequest != nil {
@@ -195,6 +233,18 @@ func (h *AuthenticationHandlers) Callback(c echo.Context) error {
}
return c.Redirect(http.StatusFound, "/a/error?e=ua")
}
if len(tailnets) == 1 {
req, err := h.repository.GetRegistrationRequestByKey(ctx, state.Key)
if err != nil {
return logError(err)
}
if req == nil {
return logError(fmt.Errorf("invalid registration key"))
}
return h.endMachineRegistrationFlow(c, EndAuthForm{AccountID: account.ID, TailnetID: tailnets[0].ID}, req)
}
return c.Render(http.StatusOK, "tailnets.html", &TailnetSelectionData{
Csrf: csrf,
Tailnets: tailnets,
@@ -203,8 +253,8 @@ func (h *AuthenticationHandlers) Callback(c echo.Context) error {
})
}
if state.Flow == "c" {
isSystemAdmin, err := h.isSystemAdmin(ctx, user)
if state.Flow == AuthFlowClient {
isSystemAdmin, err := h.isSystemAdmin(user)
if err != nil {
return logError(err)
}
@@ -228,51 +278,38 @@ func (h *AuthenticationHandlers) Callback(c echo.Context) error {
return echo.NewHTTPError(http.StatusNotFound)
}
func (h *AuthenticationHandlers) isSystemAdmin(ctx context.Context, u *auth.User) (bool, error) {
return h.systemIAMPolicy.EvaluatePolicy(&domain.Identity{UserID: u.ID, Email: u.Name, Attr: u.Attr})
}
func (h *AuthenticationHandlers) listAvailableTailnets(ctx context.Context, u *auth.User) ([]domain.Tailnet, error) {
var result = []domain.Tailnet{}
tailnets, err := h.repository.ListTailnets(ctx)
if err != nil {
return nil, err
}
for _, t := range tailnets {
approved, err := t.IAMPolicy.EvaluatePolicy(&domain.Identity{UserID: u.ID, Email: u.Name, Attr: u.Attr})
if err != nil {
return nil, err
}
if approved {
result = append(result, t)
}
}
return result, nil
}
func (h *AuthenticationHandlers) EndOAuth(c echo.Context) error {
func (h *AuthenticationHandlers) EndAuth(c echo.Context) error {
ctx := c.Request().Context()
state, err := h.readState(c.QueryParam("state"))
var form EndAuthForm
if err := c.Bind(&form); err != nil {
return logError(err)
}
state, err := h.readState(form.State)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid state parameter")
}
if state.Flow == "r" {
if state.Flow == AuthFlowMachineRegistration {
req, err := h.repository.GetRegistrationRequestByKey(ctx, state.Key)
if err != nil || req == nil {
return logError(err)
}
return h.endMachineRegistrationFlow(c, req, state)
return h.endMachineRegistrationFlow(c, form, req)
}
if state.Flow == AuthFlowClient {
req, err := h.repository.GetAuthenticationRequest(ctx, state.Key)
if err != nil || req == nil {
return logError(err)
}
return h.endCliAuthenticationFlow(c, req, state)
return h.endCliAuthenticationFlow(c, form, req)
}
return echo.NewHTTPError(http.StatusBadRequest, "Invalid state parameter")
}
func (h *AuthenticationHandlers) Success(c echo.Context) error {
@@ -299,21 +336,9 @@ func (h *AuthenticationHandlers) Error(c echo.Context) error {
return c.Render(http.StatusOK, "error.html", nil)
}
type TailnetSelectionForm struct {
AccountID uint64 `form:"aid"`
TailnetID uint64 `form:"tid"`
AsSystemAdmin bool `form:"sad"`
AuthKey string `form:"ak"`
}
func (h *AuthenticationHandlers) endCliAuthenticationFlow(c echo.Context, req *domain.AuthenticationRequest, state *oauthState) error {
func (h *AuthenticationHandlers) endCliAuthenticationFlow(c echo.Context, form EndAuthForm, req *domain.AuthenticationRequest) error {
ctx := c.Request().Context()
var form TailnetSelectionForm
if err := c.Bind(&form); err != nil {
return logError(err)
}
account, err := h.repository.GetAccount(ctx, form.AccountID)
if err != nil {
return logError(err)
@@ -371,14 +396,9 @@ func (h *AuthenticationHandlers) endCliAuthenticationFlow(c echo.Context, req *d
return c.Redirect(http.StatusFound, "/a/success")
}
func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, registrationRequest *domain.RegistrationRequest, state *oauthState) error {
func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, form EndAuthForm, registrationRequest *domain.RegistrationRequest) error {
ctx := c.Request().Context()
var form TailnetSelectionForm
if err := c.Bind(&form); err != nil {
return logError(err)
}
req := tailcfg.RegisterRequest(registrationRequest.Data)
machineKey := registrationRequest.MachineKey
nodeKey := req.NodeKey.String()
@@ -542,6 +562,28 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, regi
}
}
func (h *AuthenticationHandlers) isSystemAdmin(u *auth.User) (bool, error) {
return h.systemIAMPolicy.EvaluatePolicy(&domain.Identity{UserID: u.ID, Email: u.Name, Attr: u.Attr})
}
func (h *AuthenticationHandlers) listAvailableTailnets(ctx context.Context, u *auth.User) ([]domain.Tailnet, error) {
var result = []domain.Tailnet{}
tailnets, err := h.repository.ListTailnets(ctx)
if err != nil {
return nil, err
}
for _, t := range tailnets {
approved, err := t.IAMPolicy.EvaluatePolicy(&domain.Identity{UserID: u.ID, Email: u.Name, Attr: u.Attr})
if err != nil {
return nil, err
}
if approved {
result = append(result, t)
}
}
return result, nil
}
func (h *AuthenticationHandlers) exchangeUser(code string) (*auth.User, error) {
redirectUrl := h.config.CreateUrl("/a/callback")
@@ -553,7 +595,7 @@ func (h *AuthenticationHandlers) exchangeUser(code string) (*auth.User, error) {
return user, nil
}
func (h *AuthenticationHandlers) createState(flow string, key string) (string, error) {
func (h *AuthenticationHandlers) createState(flow AuthFlow, key string) (string, error) {
stateMap := oauthState{Key: key, Flow: flow}
marshal, err := json.Marshal(&stateMap)
if err != nil {
+1 -1
View File
@@ -174,7 +174,7 @@ func Start(c *config.Config) error {
auth.GET("/:flow/:key", authenticationHandlers.StartAuth)
auth.POST("/:flow/:key", authenticationHandlers.ProcessAuth)
auth.GET("/callback", authenticationHandlers.Callback)
auth.POST("/callback", authenticationHandlers.EndOAuth)
auth.POST("/callback", authenticationHandlers.EndAuth)
auth.GET("/success", authenticationHandlers.Success)
auth.GET("/error", authenticationHandlers.Error)
+1 -1
View File
@@ -82,7 +82,7 @@
<form method="post">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<ul class="selectionList">
<li><button type="submit" name="s" value="true">OpenID</button></li>
<li><button type="submit" name="oidc" value="true">OpenID</button></li>
</ul>
</form>
<div style="text-align: left; padding-bottom: 10px; padding-top: 20px">
+13 -12
View File
@@ -4,24 +4,25 @@ import (
"github.com/jsiebens/ionscale/pkg/defaults"
ionscalev1 "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/jsiebens/ionscale/tests/sc"
"github.com/jsiebens/ionscale/tests/tsn"
"github.com/stretchr/testify/require"
"testing"
)
func TestACL_PeersShouldBeRemovedWhenNoMatchingACLRuleIsAvailable(t *testing.T) {
sc.Run(t, func(s sc.Scenario) {
tailnet := s.CreateTailnet("acltest")
sc.Run(t, func(s *sc.Scenario) {
tailnet := s.CreateTailnet()
clientKey := s.CreateAuthKey(tailnet.Id, true, "tag:client")
serverKey := s.CreateAuthKey(tailnet.Id, true, "tag:server")
client1 := s.NewTailscaleNode("client1")
client2 := s.NewTailscaleNode("client2")
server := s.NewTailscaleNode("server")
client1 := s.NewTailscaleNode()
client2 := s.NewTailscaleNode()
server := s.NewTailscaleNode()
client1.Up(clientKey)
client2.Up(clientKey)
server.Up(serverKey)
server.WaitFor(sc.PeerCount(2))
require.NoError(t, client1.Up(clientKey))
require.NoError(t, client2.Up(clientKey))
require.NoError(t, server.Up(serverKey))
require.NoError(t, server.WaitFor(tsn.PeerCount(2)))
policy := defaults.DefaultACLPolicy()
policy.Acls = []*ionscalev1.ACL{
@@ -32,8 +33,8 @@ func TestACL_PeersShouldBeRemovedWhenNoMatchingACLRuleIsAvailable(t *testing.T)
},
}
s.SetAclPolicy(tailnet.Id, policy)
s.SetACLPolicy(tailnet.Id, policy)
server.WaitFor(sc.PeerCount(0))
require.NoError(t, server.WaitFor(tsn.PeerCount(0)))
})
}
+8 -2
View File
@@ -1,5 +1,5 @@
http_listen_addr: ":8080"
server_url: "http://localhost:8080"
http_listen_addr: ":80"
server_url: "http://ionscale"
tls:
disable: true
@@ -12,5 +12,11 @@ database:
type: sqlite
url: /opt/ionscale.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)
auth:
provider:
issuer: http://mockoidc/oidc
client_id: "foo"
client_secret: "bar"
logging:
level: debug
+30 -28
View File
@@ -2,36 +2,20 @@ package tests
import (
"github.com/jsiebens/ionscale/tests/sc"
"github.com/jsiebens/ionscale/tests/tsn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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.WaitFor(sc.PeerCount(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")
sc.Run(t, func(s *sc.Scenario) {
tailnet := s.CreateTailnet()
authKey := s.CreateAuthKey(tailnet.Id, false)
tsNode := s.NewTailscaleNode("testip")
tsNode := s.NewTailscaleNode()
tsNode.Up(authKey)
require.NoError(t, tsNode.Up(authKey))
ip4 := tsNode.IPv4()
ip6 := tsNode.IPv6()
@@ -49,18 +33,36 @@ func TestGetIPs(t *testing.T) {
})
}
func TestPing(t *testing.T) {
sc.Run(t, func(s *sc.Scenario) {
tailnet := s.CreateTailnet()
key := s.CreateAuthKey(tailnet.Id, true)
nodeA := s.NewTailscaleNode()
nodeB := s.NewTailscaleNode()
require.NoError(t, nodeA.Up(key))
require.NoError(t, nodeB.Up(key))
require.NoError(t, nodeA.WaitFor(tsn.PeerCount(1)))
require.NoError(t, nodeA.Ping(nodeB.Hostname()))
require.NoError(t, nodeA.Ping(nodeB.IPv4()))
require.NoError(t, nodeA.Ping(nodeB.IPv6()))
})
}
func TestNodeWithSameHostname(t *testing.T) {
sc.Run(t, func(s sc.Scenario) {
tailnet := s.CreateTailnet("tailnet01")
sc.Run(t, func(s *sc.Scenario) {
tailnet := s.CreateTailnet()
authKey := s.CreateAuthKey(tailnet.Id, false)
tsNode := s.NewTailscaleNode("test")
tsNode := s.NewTailscaleNode(sc.WithName("test"))
tsNode.Up(authKey)
require.NoError(t, tsNode.Up(authKey))
for i := 0; i < 5; i++ {
tc := s.NewTailscaleNode("test")
tc.Up(authKey)
tc := s.NewTailscaleNode(sc.WithName("test"))
require.NoError(t, tc.Up(authKey))
}
machines := make(map[string]bool)
+15 -13
View File
@@ -4,17 +4,19 @@ import (
"github.com/jsiebens/ionscale/pkg/defaults"
ionscalev1 "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/jsiebens/ionscale/tests/sc"
"github.com/jsiebens/ionscale/tests/tsn"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"testing"
)
func TestNodeAttrs(t *testing.T) {
sc.Run(t, func(s sc.Scenario) {
tailnet := s.CreateTailnet("nodeattrs")
sc.Run(t, func(s *sc.Scenario) {
tailnet := s.CreateTailnet()
key := s.CreateAuthKey(tailnet.Id, true)
nodeA := s.NewTailscaleNode("test-a")
nodeA.Up(key)
nodeA := s.NewTailscaleNode()
require.NoError(t, nodeA.Up(key))
policy := defaults.DefaultACLPolicy()
policy.Nodeattrs = []*ionscalev1.NodeAttr{
@@ -24,19 +26,19 @@ func TestNodeAttrs(t *testing.T) {
},
}
s.SetAclPolicy(tailnet.Id, policy)
s.SetACLPolicy(tailnet.Id, policy)
nodeA.WaitFor(sc.HasCapability("ionscale:test"))
require.NoError(t, nodeA.WaitFor(tsn.HasCapability("ionscale:test")))
})
}
func TestNodeAttrs_IgnoreFunnelAttr(t *testing.T) {
sc.Run(t, func(s sc.Scenario) {
tailnet := s.CreateTailnet("nodeattrs")
sc.Run(t, func(s *sc.Scenario) {
tailnet := s.CreateTailnet()
key := s.CreateAuthKey(tailnet.Id, true)
nodeA := s.NewTailscaleNode("test-a")
nodeA.Up(key)
nodeA := s.NewTailscaleNode()
require.NoError(t, nodeA.Up(key))
policy := defaults.DefaultACLPolicy()
policy.Nodeattrs = []*ionscalev1.NodeAttr{
@@ -46,9 +48,9 @@ func TestNodeAttrs_IgnoreFunnelAttr(t *testing.T) {
},
}
s.SetAclPolicy(tailnet.Id, policy)
s.SetACLPolicy(tailnet.Id, policy)
nodeA.WaitFor(sc.HasCapability("ionscale:test"))
nodeA.WaitFor(sc.IsMissingCapability(tailcfg.NodeAttrFunnel))
require.NoError(t, nodeA.WaitFor(tsn.HasCapability("ionscale:test")))
require.NoError(t, nodeA.WaitFor(tsn.IsMissingCapability(tailcfg.NodeAttrFunnel)))
})
}
-49
View File
@@ -1,49 +0,0 @@
package sc
import (
"slices"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
)
func PeerCount(expected int) func(*ipnstate.Status) bool {
return func(status *ipnstate.Status) bool {
return len(status.Peers()) == expected
}
}
func HasCapability(capability tailcfg.NodeCapability) func(*ipnstate.Status) bool {
return func(status *ipnstate.Status) bool {
self := status.Self
if self == nil {
return false
}
if slices.Contains(self.Capabilities, capability) {
return true
}
if _, ok := self.CapMap[capability]; ok {
return true
}
return false
}
}
func IsMissingCapability(capability tailcfg.NodeCapability) func(*ipnstate.Status) bool {
return func(status *ipnstate.Status) bool {
self := status.Self
if slices.Contains(self.Capabilities, capability) {
return false
}
if _, ok := self.CapMap[capability]; ok {
return false
}
return true
}
}
-147
View File
@@ -1,147 +0,0 @@
package sc
import (
"bytes"
"encoding/json"
"fmt"
"github.com/ory/dockertest/v3"
"github.com/stretchr/testify/require"
"strings"
"tailscale.com/ipn/ipnstate"
"testing"
)
type TailscaleNode interface {
Hostname() string
Up(authkey string)
IPv4() string
IPv6() string
Ping(target string)
WaitFor(func(*ipnstate.Status) bool)
}
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) {
t.mustExecTailscaleCmd("up", "--login-server", t.loginServer, "--authkey", authkey)
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")
})
require.NoError(t.t, 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
})
require.NoError(t.t, err)
}
func (t *tailscaleNode) WaitFor(check func(status *ipnstate.Status) bool) {
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 !check(&status) {
return fmt.Errorf("condition not met")
}
return nil
})
require.NoError(t.t, err)
}
func (t *tailscaleNode) Ping(target string) {
result, err := t.execTailscaleCmd("ping", "--timeout=1s", "--c=10", "--until-direct=true", target)
require.NoError(t.t, err)
require.True(t.t, strings.Contains(result, "pong") || strings.Contains(result, "is local"), "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...)
require.NoError(t.t, 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
}
+92 -34
View File
@@ -4,9 +4,14 @@ import (
"context"
"fmt"
"github.com/bufbuild/connect-go"
petname "github.com/dustinkirkland/golang-petname"
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/jsiebens/ionscale/tests/tsn"
"github.com/oauth2-proxy/mockoidc"
mockoidcv1 "github.com/oauth2-proxy/mockoidc/pkg/gen/mockoidc/v1"
"github.com/oauth2-proxy/mockoidc/pkg/gen/mockoidc/v1/mockoidcv1connect"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"github.com/stretchr/testify/require"
@@ -30,54 +35,88 @@ var (
pool *dockertest.Pool
)
type Scenario interface {
NewTailscaleNode(hostname string) TailscaleNode
ListMachines(tailnetID uint64) []*api.Machine
CreateAuthKey(tailnetID uint64, ephemeral bool, tags ...string) string
CreateTailnet(name string) *api.Tailnet
SetAclPolicy(tailnetID uint64, policy *api.ACLPolicy)
}
type scenario struct {
type Scenario struct {
t *testing.T
pool *dockertest.Pool
network *dockertest.Network
mockoidc *dockertest.Resource
ionscale *dockertest.Resource
resources []*dockertest.Resource
client ionscaleconnect.IonscaleServiceClient
ionscaleClient ionscaleconnect.IonscaleServiceClient
mockoidcClient mockoidcv1connect.MockOIDCServiceClient
}
func (s *scenario) CreateTailnet(name string) *api.Tailnet {
createTailnetResponse, err := s.client.CreateTailnet(context.Background(), connect.NewRequest(&api.CreateTailnetRequest{Name: name}))
func (s *Scenario) CreateTailnet() *api.Tailnet {
name := petname.Generate(3, "-")
createTailnetResponse, err := s.ionscaleClient.CreateTailnet(context.Background(), connect.NewRequest(&api.CreateTailnetRequest{Name: name}))
require.NoError(s.t, err)
return createTailnetResponse.Msg.GetTailnet()
}
func (s *scenario) CreateAuthKey(tailnetID uint64, ephemeral bool, tags ...string) string {
func (s *Scenario) CreateAuthKey(tailnetID uint64, ephemeral bool, tags ...string) string {
if len(tags) == 0 {
tags = []string{"tag:test"}
}
key, err := s.client.CreateAuthKey(context.Background(), connect.NewRequest(&api.CreateAuthKeyRequest{TailnetId: tailnetID, Ephemeral: ephemeral, Tags: tags, Expiry: durationpb.New(60 * time.Minute)}))
key, err := s.ionscaleClient.CreateAuthKey(context.Background(), connect.NewRequest(&api.CreateAuthKeyRequest{TailnetId: tailnetID, Ephemeral: ephemeral, Tags: tags, Expiry: durationpb.New(60 * time.Minute)}))
require.NoError(s.t, 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}))
func (s *Scenario) ListMachines(tailnetID uint64) []*api.Machine {
machines, err := s.ionscaleClient.ListMachines(context.Background(), connect.NewRequest(&api.ListMachinesRequest{TailnetId: tailnetID}))
require.NoError(s.t, err)
return machines.Msg.Machines
}
func (s *scenario) SetAclPolicy(tailnetID uint64, policy *api.ACLPolicy) {
_, err := s.client.SetACLPolicy(context.Background(), connect.NewRequest(&api.SetACLPolicyRequest{TailnetId: tailnetID, Policy: policy}))
func (s *Scenario) AuthorizeMachines(tailnetID uint64) {
machines := s.ListMachines(tailnetID)
for _, m := range machines {
_, err := s.ionscaleClient.AuthorizeMachine(context.Background(), connect.NewRequest(&api.AuthorizeMachineRequest{MachineId: m.Id}))
require.NoError(s.t, err)
}
}
func (s *Scenario) SetACLPolicy(tailnetID uint64, policy *api.ACLPolicy) {
_, err := s.ionscaleClient.SetACLPolicy(context.Background(), connect.NewRequest(&api.SetACLPolicyRequest{TailnetId: tailnetID, Policy: policy}))
require.NoError(s.t, err)
}
func (s *scenario) NewTailscaleNode(hostname string) TailscaleNode {
tailscaleOptions := &dockertest.RunOptions{
func (s *Scenario) SetIAMPolicy(tailnetID uint64, policy *api.IAMPolicy) {
_, err := s.ionscaleClient.SetIAMPolicy(context.Background(), connect.NewRequest(&api.SetIAMPolicyRequest{TailnetId: tailnetID, Policy: policy}))
require.NoError(s.t, err)
}
func (s *Scenario) EnableMachineAutorization(tailnetID uint64) {
_, err := s.ionscaleClient.EnableMachineAuthorization(context.Background(), connect.NewRequest(&api.EnableMachineAuthorizationRequest{TailnetId: tailnetID}))
require.NoError(s.t, err)
}
func (s *Scenario) PushOIDCUser(sub, email, preferredUsername string) {
_, err := s.mockoidcClient.PushUser(context.Background(), connect.NewRequest(&mockoidcv1.PushUserRequest{Subject: sub, Email: email, PreferredUsername: preferredUsername}))
require.NoError(s.t, err)
}
type TailscaleNodeConfig struct {
Hostname string
}
type TailscaleNodeOpt = func(*TailscaleNodeConfig)
func WithName(name string) TailscaleNodeOpt {
return func(config *TailscaleNodeConfig) {
config.Hostname = name
}
}
func (s *Scenario) NewTailscaleNode(opts ...TailscaleNodeOpt) *tsn.TailscaleNode {
config := &TailscaleNodeConfig{Hostname: petname.Generate(3, "-")}
for _, o := range opts {
o(config)
}
runOpts := &dockertest.RunOptions{
Repository: fmt.Sprintf("ts-%s", strings.Replace(targetVersion, ".", "-", -1)),
Hostname: hostname,
Hostname: config.Hostname,
Networks: []*dockertest.Network{s.network},
ExposedPorts: []string{"1055"},
Cmd: []string{
@@ -86,7 +125,7 @@ func (s *scenario) NewTailscaleNode(hostname string) TailscaleNode {
}
resource, err := s.pool.RunWithOptions(
tailscaleOptions,
runOpts,
restartPolicy,
)
require.NoError(s.t, err)
@@ -96,15 +135,10 @@ func (s *scenario) NewTailscaleNode(hostname string) TailscaleNode {
s.resources = append(s.resources, resource)
return &tailscaleNode{
t: s.t,
loginServer: "http://ionscale:8080",
hostname: hostname,
resource: resource,
}
return tsn.New(s.t, config.Hostname, "http://ionscale", resource, s.pool.Retry)
}
func Run(t *testing.T, f func(s Scenario)) {
func Run(t *testing.T, f func(s *Scenario)) {
if testing.Short() {
t.Skip("skipped due to -short flag")
}
@@ -116,7 +150,7 @@ func Run(t *testing.T, f func(s Scenario)) {
}
var err error
s := &scenario{t: t}
s := &Scenario{t: t}
defer func() {
for _, r := range s.resources {
@@ -127,6 +161,10 @@ func Run(t *testing.T, f func(s Scenario)) {
_ = pool.Purge(s.ionscale)
}
if s.mockoidc != nil {
_ = pool.Purge(s.mockoidc)
}
if s.network != nil {
_ = s.network.Close()
}
@@ -144,6 +182,26 @@ func Run(t *testing.T, f func(s Scenario)) {
currentPath, err := os.Getwd()
require.NoError(s.t, err)
// run mockoidc container
{
mockoidcOpts := &dockertest.RunOptions{
Hostname: "mockoidc",
Repository: "ghcr.io/jsiebens/mockoidc",
Networks: []*dockertest.Network{s.network},
ExposedPorts: []string{"80"},
Cmd: []string{"--listen-addr", ":80", "--server-url", "http://mockoidc"},
}
s.mockoidc, err = pool.RunWithOptions(mockoidcOpts, restartPolicy)
require.NoError(s.t, err)
port := s.mockoidc.GetPort("80/tcp")
err = pool.Retry(httpCheck(port, "/oidc/.well-known/openid-configuration"))
require.NoError(s.t, err)
s.mockoidcClient = mockoidc.NewClient(fmt.Sprintf("http://localhost:%s", port), true)
}
ionscale := &dockertest.RunOptions{
Hostname: "ionscale",
Repository: "ionscale-test",
@@ -151,14 +209,14 @@ func Run(t *testing.T, f func(s Scenario)) {
fmt.Sprintf("%s/config:/etc/ionscale", currentPath),
},
Networks: []*dockertest.Network{s.network},
ExposedPorts: []string{"8080"},
ExposedPorts: []string{"80"},
Cmd: []string{"server", "--config", "/etc/ionscale/config.yaml"},
}
s.ionscale, err = pool.RunWithOptions(ionscale, restartPolicy)
require.NoError(s.t, err)
port := s.ionscale.GetPort("8080/tcp")
port := s.ionscale.GetPort("80/tcp")
err = pool.Retry(httpCheck(port, "/key"))
require.NoError(s.t, err)
@@ -166,7 +224,7 @@ func Run(t *testing.T, f func(s Scenario)) {
auth, err := ionscaleclt.LoadClientAuth("804ecd57365342254ce6647da5c249e85c10a0e51e74856bfdf292a2136b4249")
require.NoError(s.t, err)
s.client, err = ionscaleclt.NewClient(auth, fmt.Sprintf("http://localhost:%s", port), true)
s.ionscaleClient, err = ionscaleclt.NewClient(auth, fmt.Sprintf("http://localhost:%s", port), true)
require.NoError(s.t, err)
f(s)
+96
View File
@@ -0,0 +1,96 @@
package tsn
import (
"slices"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
)
type Condition = func(*ipnstate.Status) bool
func Connected() Condition {
return func(status *ipnstate.Status) bool {
return status.CurrentTailnet != nil
}
}
func HasTailnet(tailnet string) Condition {
return func(status *ipnstate.Status) bool {
return status.CurrentTailnet != nil && status.CurrentTailnet.Name == tailnet
}
}
func HasTag(tag string) Condition {
return func(status *ipnstate.Status) bool {
return status.Self != nil && status.Self.Tags != nil && views.SliceContains[string](*status.Self.Tags, tag)
}
}
func NeedsMachineAuth() Condition {
return func(status *ipnstate.Status) bool {
return status.BackendState == "NeedsMachineAuth"
}
}
func IsRunning() Condition {
return func(status *ipnstate.Status) bool {
return status.BackendState == "Running"
}
}
func HasUser(email string) Condition {
return func(status *ipnstate.Status) bool {
if status.Self == nil {
return false
}
userID := status.Self.UserID
if u, ok := status.User[userID]; ok {
return u.LoginName == email
}
return false
}
}
func PeerCount(expected int) Condition {
return func(status *ipnstate.Status) bool {
return len(status.Peers()) == expected
}
}
func HasCapability(capability tailcfg.NodeCapability) Condition {
return func(status *ipnstate.Status) bool {
self := status.Self
if self == nil {
return false
}
if slices.Contains(self.Capabilities, capability) {
return true
}
if _, ok := self.CapMap[capability]; ok {
return true
}
return false
}
}
func IsMissingCapability(capability tailcfg.NodeCapability) Condition {
return func(status *ipnstate.Status) bool {
self := status.Self
if slices.Contains(self.Capabilities, capability) {
return false
}
if _, ok := self.CapMap[capability]; ok {
return false
}
return true
}
}
+236
View File
@@ -0,0 +1,236 @@
package tsn
import (
"bytes"
"encoding/json"
"fmt"
"github.com/ory/dockertest/v3"
"github.com/stretchr/testify/require"
"net/http"
"net/url"
"strconv"
"strings"
"tailscale.com/ipn/ipnstate"
"testing"
"time"
)
func New(t *testing.T, name, loginServer string, resource *dockertest.Resource, retry func(func() error) error) *TailscaleNode {
return &TailscaleNode{
t: t,
loginServer: loginServer,
hostname: name,
resource: resource,
retry: retry,
}
}
type TailscaleNode struct {
t *testing.T
loginServer string
hostname string
resource *dockertest.Resource
retry func(func() error) error
}
func (t *TailscaleNode) Hostname() string {
return t.hostname
}
func (t *TailscaleNode) Up(authkey string) error {
t.mustExecTailscaleCmd("up", "--login-server", t.loginServer, "--authkey", authkey)
return t.WaitFor(Connected())
}
func (t *TailscaleNode) LoginWithOidc(flags ...UpFlag) (int, error) {
check := func(stdout, stderr string) bool {
return strings.Contains(stderr, "To authenticate, visit:")
}
cmd := []string{"up", "--login-server", t.loginServer}
for _, f := range flags {
cmd = append(cmd, f...)
}
_, stderr, err := t.execTailscaleCmdWithCheck(check, cmd...)
require.NoError(t.t, err)
urlStr := strings.ReplaceAll(stderr, "To authenticate, visit:\n\n\t", "")
urlStr = strings.TrimSpace(urlStr)
u, err := url.Parse(urlStr)
require.NoError(t.t, err)
q := u.Query()
q.Set("oidc", "true")
u.RawQuery = q.Encode()
responseCode, err := t.curl(u)
require.NoError(t.t, err)
if responseCode == http.StatusOK {
return responseCode, t.WaitFor(Connected())
}
return responseCode, nil
}
func (t *TailscaleNode) IPv4() string {
return t.mustExecTailscaleCmd("ip", "-4")
}
func (t *TailscaleNode) IPv6() string {
return t.mustExecTailscaleCmd("ip", "-6")
}
func (t *TailscaleNode) WaitFor(c Condition, additional ...Condition) error {
err := t.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 !c(&status) {
return fmt.Errorf("condition not met")
}
for _, a := range additional {
if !a(&status) {
return fmt.Errorf("condition not met")
}
}
return nil
})
return err
}
func (t *TailscaleNode) Check(c Condition, additional ...Condition) 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 !c(&status) {
return fmt.Errorf("condition not met")
}
for _, a := range additional {
if !a(&status) {
return fmt.Errorf("condition not met")
}
}
return nil
}
func (t *TailscaleNode) Ping(target string) error {
result, _, err := t.execTailscaleCmd("ping", "--timeout=1s", "--c=10", "--until-direct=true", target)
if err != nil {
return err
}
if !strings.Contains(result, "pong") && !strings.Contains(result, "is local") {
return fmt.Errorf("ping failed")
}
return nil
}
func (t *TailscaleNode) curl(url *url.URL) (int, error) {
cmd := []string{"curl", "-L", "-s", "-o", "/dev/null", "-w", "%{http_code}", url.String()}
stdout, _, err := execCmd(t.resource, cmd...)
if err != nil {
return 0, err
}
return strconv.Atoi(stdout)
}
func (t *TailscaleNode) execTailscaleCmd(cmd ...string) (string, string, error) {
i := append([]string{"/app/tailscale", "--socket=/tmp/tailscaled.sock"}, cmd...)
return execCmd(t.resource, i...)
}
func (t *TailscaleNode) execTailscaleCmdWithCheck(check func(string, string) bool, cmd ...string) (string, string, error) {
i := append([]string{"/app/tailscale", "--socket=/tmp/tailscaled.sock"}, cmd...)
return execCmdWithCheck(t.resource, check, i...)
}
func (t *TailscaleNode) mustExecTailscaleCmd(cmd ...string) string {
i := append([]string{"/app/tailscale", "--socket=/tmp/tailscaled.sock"}, cmd...)
s, _, err := execCmd(t.resource, i...)
require.NoError(t.t, err)
return s
}
func execCmd(resource *dockertest.Resource, cmd ...string) (string, string, error) {
return execCmdWithCheck(resource, nil, cmd...)
}
func execCmdWithCheck(resource *dockertest.Resource, check func(string, string) bool, cmd ...string) (string, string, error) {
tr := strings.TrimSpace
var stdout bytes.Buffer
var stderr bytes.Buffer
type result struct {
exitCode int
err error
}
resultChan := make(chan result, 1)
checkChan := make(chan bool, 1)
go func() {
exitCode, err := resource.Exec(cmd, dockertest.ExecOptions{StdOut: &stdout, StdErr: &stderr})
resultChan <- result{exitCode, err}
}()
if check != nil {
done := make(chan bool)
ticker := time.NewTicker(2 * time.Second)
defer func() {
ticker.Stop()
done <- true
close(done)
}()
go func() {
for {
select {
case <-done:
return
case <-ticker.C:
if check(tr(stdout.String()), tr(stderr.String())) {
checkChan <- true
}
}
}
}()
}
select {
case <-checkChan:
return tr(stdout.String()), tr(stderr.String()), nil
case res := <-resultChan:
if res.err != nil {
return stdout.String(), stderr.String(), res.err
}
if res.exitCode != 0 {
return tr(stdout.String()), tr(stderr.String()), fmt.Errorf("invalid exit code %d", res.exitCode)
}
return tr(stdout.String()), tr(stderr.String()), nil
case <-time.After(60 * time.Second):
return tr(stdout.String()), tr(stderr.String()), fmt.Errorf("command timed out")
}
}
+7
View File
@@ -0,0 +1,7 @@
package tsn
type UpFlag = []string
func WithAdvertiseTags(tags string) UpFlag {
return []string{"--advertise-tags", tags}
}
+151
View File
@@ -0,0 +1,151 @@
package tests
import (
"github.com/jsiebens/ionscale/pkg/defaults"
api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1"
"github.com/jsiebens/ionscale/tests/sc"
"github.com/jsiebens/ionscale/tests/tsn"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
"net/http"
"tailscale.com/tailcfg"
"testing"
)
func newTailscaleNodeAndLoginWithOIDC(t *testing.T, s *sc.Scenario, expectedLoginName string, flags ...tsn.UpFlag) *tsn.TailscaleNode {
node := s.NewTailscaleNode()
code, err := node.LoginWithOidc(flags...)
require.NoError(t, err)
require.Equal(t, http.StatusOK, code)
require.NoError(t, node.WaitFor(tsn.Connected()))
require.NoError(t, node.WaitFor(tsn.HasUser(expectedLoginName)))
return node
}
func TestWebLoginWithDomainFilterInIAMPolicy(t *testing.T) {
sc.Run(t, func(s *sc.Scenario) {
s.PushOIDCUser("123", "john@localtest.me", "john")
s.PushOIDCUser("124", "jane@localtest.me", "jane")
tailnet := s.CreateTailnet()
s.SetIAMPolicy(tailnet.Id, &api.IAMPolicy{Filters: []string{"domain == localtest.me"}})
john := newTailscaleNodeAndLoginWithOIDC(t, s, "john@localtest.me")
jane := newTailscaleNodeAndLoginWithOIDC(t, s, "jane@localtest.me")
require.NoError(t, john.Check(tsn.HasTailnet(tailnet.Name)))
require.NoError(t, jane.Check(tsn.HasTailnet(tailnet.Name)))
require.NoError(t, john.WaitFor(tsn.PeerCount(1)))
require.NoError(t, jane.WaitFor(tsn.PeerCount(1)))
})
}
func TestWebLoginWithSubsAndEmailsInIAMPolicy(t *testing.T) {
sc.Run(t, func(s *sc.Scenario) {
s.PushOIDCUser("123", "john@localtest.me", "john")
s.PushOIDCUser("124", "jane@localtest.me", "jane")
tailnet := s.CreateTailnet()
s.SetIAMPolicy(tailnet.Id, &api.IAMPolicy{Subs: []string{"123"}, Emails: []string{"jane@localtest.me"}})
john := newTailscaleNodeAndLoginWithOIDC(t, s, "john@localtest.me")
jane := newTailscaleNodeAndLoginWithOIDC(t, s, "jane@localtest.me")
require.NoError(t, john.WaitFor(tsn.PeerCount(1)))
require.NoError(t, jane.WaitFor(tsn.PeerCount(1)))
})
}
func TestWebLoginWithUserAsTailnetAdmin(t *testing.T) {
sc.Run(t, func(s *sc.Scenario) {
s.PushOIDCUser("123", "john@localtest.me", "john")
s.PushOIDCUser("124", "jane@localtest.me", "jane")
tailnet := s.CreateTailnet()
s.SetIAMPolicy(tailnet.Id, &api.IAMPolicy{
Filters: []string{"domain == localtest.me"},
Roles: map[string]string{"john@localtest.me": "admin"},
})
john := newTailscaleNodeAndLoginWithOIDC(t, s, "john@localtest.me")
jane := newTailscaleNodeAndLoginWithOIDC(t, s, "jane@localtest.me")
require.NoError(t, john.Check(tsn.HasCapability(tailcfg.CapabilityAdmin)))
require.NoError(t, jane.Check(tsn.IsMissingCapability(tailcfg.CapabilityAdmin)))
})
}
func TestWebLoginWhenNotAuthorizedForAnyTailnet(t *testing.T) {
sc.Run(t, func(s *sc.Scenario) {
s.PushOIDCUser("124", "jane@localtest.me", "jane")
tailnet := s.CreateTailnet()
s.SetIAMPolicy(tailnet.Id, &api.IAMPolicy{
Subs: []string{"123"},
})
jane := s.NewTailscaleNode()
code, err := jane.LoginWithOidc()
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, code)
})
}
func TestWebLoginWhenInvalidTagOwner(t *testing.T) {
sc.Run(t, func(s *sc.Scenario) {
s.PushOIDCUser("124", "jane@localtest.me", "jane")
tailnet := s.CreateTailnet()
s.SetIAMPolicy(tailnet.Id, &api.IAMPolicy{
Subs: []string{"124"},
})
jane := s.NewTailscaleNode()
code, err := jane.LoginWithOidc(tsn.WithAdvertiseTags("tag:test"))
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, code)
})
}
func TestWebLoginAsTagOwner(t *testing.T) {
sc.Run(t, func(s *sc.Scenario) {
s.PushOIDCUser("124", "jane@localtest.me", "jane")
owners, err := structpb.NewList([]interface{}{"jane@localtest.me"})
require.NoError(t, err)
aclPolicy := defaults.DefaultACLPolicy()
aclPolicy.Tagowners = map[string]*structpb.ListValue{
"tag:localtest": owners,
}
tailnet := s.CreateTailnet()
s.SetACLPolicy(tailnet.Id, aclPolicy)
s.SetIAMPolicy(tailnet.Id, &api.IAMPolicy{
Subs: []string{"124"},
})
newTailscaleNodeAndLoginWithOIDC(t, s, "tagged-devices", tsn.WithAdvertiseTags("tag:localtest"))
})
}
func TestWebLoginWithMachineAuthorizationRequired(t *testing.T) {
sc.Run(t, func(s *sc.Scenario) {
s.PushOIDCUser("123", "john@localtest.me", "john")
tailnet := s.CreateTailnet()
s.SetIAMPolicy(tailnet.Id, &api.IAMPolicy{Filters: []string{"domain == localtest.me"}})
s.EnableMachineAutorization(tailnet.Id)
node := newTailscaleNodeAndLoginWithOIDC(t, s, "john@localtest.me")
require.NoError(t, node.Check(tsn.NeedsMachineAuth()))
s.AuthorizeMachines(tailnet.Id)
require.NoError(t, node.WaitFor(tsn.IsRunning()))
})
}