Skip to content

Commit

Permalink
Add a mockssh SSH server package
Browse files Browse the repository at this point in the history
  • Loading branch information
pjcdawkins committed Dec 30, 2024
1 parent 341c013 commit b7b5bb5
Show file tree
Hide file tree
Showing 6 changed files with 469 additions and 81 deletions.
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ require (
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
github.com/wk8/go-ordered-map/v2 v2.1.8
golang.org/x/crypto v0.24.0
golang.org/x/crypto v0.31.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down Expand Up @@ -62,9 +62,9 @@ require (
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/term v0.21.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.21.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)
Expand Down
18 changes: 8 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,6 @@ github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/platformsh/platformify v0.2.11 h1:9TRej4tDgQahRfl1tDOGaCry79yXYXbzDR1ZMdOPsU8=
github.com/platformsh/platformify v0.2.11/go.mod h1:fgmCcfQfHbhe1oXsIdIhpnniyZu8IdIMOlcBAa/ygic=
github.com/platformsh/platformify v0.2.12 h1:IhRI+TZUe/sGhjWySSOMVm+si1aRyivp3LZieMJid+4=
github.com/platformsh/platformify v0.2.12/go.mod h1:fgmCcfQfHbhe1oXsIdIhpnniyZu8IdIMOlcBAa/ygic=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Expand Down Expand Up @@ -157,8 +155,8 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
Expand All @@ -179,19 +177,19 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
Expand Down
162 changes: 101 additions & 61 deletions pkg/mockapi/auth_server.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package mockapi

import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
Expand All @@ -10,6 +11,8 @@ import (
"testing"
"time"

"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
Expand All @@ -20,85 +23,122 @@ var accessTokens = []string{"access-token-1"}
// NewAuthServer creates a new mock authentication server.
// The caller must call Close() on the server when finished.
func NewAuthServer(t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if testing.Verbose() {
t.Log(req)
}
if req.Method == http.MethodPost && req.URL.Path == "/oauth2/token" {
require.NoError(t, req.ParseForm())
if gt := req.Form.Get("grant_type"); gt != "api_token" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid grant type: " + gt})
return
}
apiToken := req.Form.Get("api_token")
if slices.Contains(ValidAPITokens, apiToken) {
_ = json.NewEncoder(w).Encode(struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Type string `json:"token_type"`
}{AccessToken: accessTokens[0], ExpiresIn: 60, Type: "bearer"})
return
}
mux := chi.NewRouter()
if testing.Verbose() {
mux.Use(middleware.DefaultLogger)
}

mux.Post("/oauth2/token", func(w http.ResponseWriter, req *http.Request) {
require.NoError(t, req.ParseForm())
if gt := req.Form.Get("grant_type"); gt != "api_token" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid API token"})
_ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid grant type: " + gt})
return
}
apiToken := req.Form.Get("api_token")
if slices.Contains(ValidAPITokens, apiToken) {
_ = json.NewEncoder(w).Encode(struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Type string `json:"token_type"`
}{AccessToken: accessTokens[0], ExpiresIn: 60, Type: "bearer"})
return
}
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid API token"})
})

if req.Method == http.MethodPost && req.URL.Path == "/ssh" {
var options struct {
PublicKey string `json:"key"`
}
err := json.NewDecoder(req.Body).Decode(&options)
require.NoError(t, err)
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(options.PublicKey))
mux.Get("/ssh/authority", func(w http.ResponseWriter, _ *http.Request) {
pks, err := publicKeys()
require.NoError(t, err)
data := struct {
Authorities []string `json:"authorities"`
}{}
for _, k := range pks {
sshPubKey, err := ssh.NewPublicKey(k)
require.NoError(t, err)
signer, err := sshSigner()
require.NoError(t, err)
extensions := make(map[string]string)
data.Authorities = append(data.Authorities, string(ssh.MarshalAuthorizedKey(sshPubKey)))
}
_ = json.NewEncoder(w).Encode(data)
})

// Add standard ssh options
extensions["permit-X11-forwarding"] = ""
extensions["permit-agent-forwarding"] = ""
extensions["permit-port-forwarding"] = ""
extensions["permit-pty"] = ""
extensions["permit-user-rc"] = ""
cert := &ssh.Certificate{
Key: key,
Serial: 0,
CertType: ssh.UserCert,
KeyId: "test-key-id",
ValidAfter: uint64(time.Now().Add(-1 * time.Second).Unix()),
ValidBefore: uint64(time.Now().Add(time.Minute).Unix()),
Permissions: ssh.Permissions{
Extensions: extensions,
},
}
err = cert.SignCert(rand.Reader, signer)
require.NoError(t, err)
_ = json.NewEncoder(w).Encode(struct {
Cert string `json:"certificate"`
}{string(ssh.MarshalAuthorizedKey(cert))})
require.NoError(t, err)
return
mux.Post("/ssh", func(w http.ResponseWriter, req *http.Request) {
var options struct {
PublicKey string `json:"key"`
}
err := json.NewDecoder(req.Body).Decode(&options)
require.NoError(t, err)
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(options.PublicKey))
require.NoError(t, err)
signer, err := sshSigner()
require.NoError(t, err)
extensions := make(map[string]string)

w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
}))
// Add standard ssh options
extensions["permit-X11-forwarding"] = ""
extensions["permit-agent-forwarding"] = ""
extensions["permit-port-forwarding"] = ""
extensions["permit-pty"] = ""
extensions["permit-user-rc"] = ""
cert := &ssh.Certificate{
Key: key,
Serial: 0,
CertType: ssh.UserCert,
KeyId: "test-key-id",
ValidAfter: uint64(time.Now().Add(-1 * time.Second).Unix()),
ValidBefore: uint64(time.Now().Add(time.Minute).Unix()),
Permissions: ssh.Permissions{
Extensions: extensions,
},
}
err = cert.SignCert(rand.Reader, signer)
require.NoError(t, err)
_ = json.NewEncoder(w).Encode(struct {
Cert string `json:"certificate"`
}{string(ssh.MarshalAuthorizedKey(cert))})
})

return httptest.NewServer(mux)
}

// publicKeys returns the server's public keys, e.g. for SSH certificate generation.
func publicKeys() ([]crypto.PublicKey, error) {
pub, _, err := keyPair()
if err != nil {
return nil, err
}

return []crypto.PublicKey{pub}, nil
}

var (
privateKey crypto.PrivateKey
publicKey crypto.PublicKey
)

func keyPair() (crypto.PublicKey, crypto.PrivateKey, error) {
if privateKey == nil || publicKey == nil {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
privateKey = priv
publicKey = pub
}
return publicKey, privateKey, nil
}

var signer ssh.Signer // TODO reuse to validate SSH connection
var signer ssh.Signer

func sshSigner() (ssh.Signer, error) {
if signer != nil {
return signer, nil
}
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
_, priv, err := keyPair()
if err != nil {
return nil, err
}
s, err := ssh.NewSignerFromKey(privateKey)
s, err := ssh.NewSignerFromKey(priv)
if err != nil {
return nil, err
}
Expand Down
35 changes: 35 additions & 0 deletions pkg/mockapi/id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package mockapi

import "math/rand/v2"

const lowercaseAlphanumericChars = "abcdefghijklmnopqrstuvwxyz0123456789"

func randomLength(minLen, maxLen int) int {
return rand.IntN(maxLen-minLen) + minLen //nolint:gosec
}

// ProjectID generates a random project ID.
func ProjectID() string {
return lowercaseAlphanumericID(randomLength(10, 15))
}

// lowercaseAlphanumericID generates a random lowercase alphanumeric ID.
func lowercaseAlphanumericID(length int) string {
id := make([]byte, length)
for i := range id {
id[i] = lowercaseAlphanumericChars[rand.IntN(len(lowercaseAlphanumericChars))] //nolint:gosec
}

return string(id)
}

// NumericID generates a random numeric ID.
func NumericID() string {
length := randomLength(6, 10)
id := make([]byte, length)
for i := range id {
id[i] = '0' + byte(rand.IntN(10)) //nolint:gosec
}

return string(id)
}
10 changes: 4 additions & 6 deletions pkg/mockapi/subscriptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package mockapi

import (
"encoding/json"
"fmt"
"math/rand"
"net/http"
"net/url"
"time"
Expand All @@ -19,11 +17,11 @@ func (h *Handler) handleCreateSubscription(w http.ResponseWriter, req *http.Requ
}{}
err := json.NewDecoder(req.Body).Decode(&createOptions)
require.NoError(h.t, err)
id := fmt.Sprint(rand.Int()) //nolint:gosec
projectID := "p" + id
id := NumericID()
projectID := ProjectID()
sub := Subscription{
ID: "s" + id,
Links: MakeHALLinks("self=" + "/subscriptions/" + url.PathEscape("s"+id)),
ID: id,
Links: MakeHALLinks("self=" + "/subscriptions/" + url.PathEscape(id)),
ProjectRegion: createOptions.Region,
ProjectTitle: createOptions.Title,
Status: "provisioning",
Expand Down
Loading

0 comments on commit b7b5bb5

Please sign in to comment.