This commit is contained in:
Ho Sy Tan 2025-03-29 10:29:44 +07:00
commit 6212cc3fbb
24 changed files with 3148 additions and 0 deletions

14
.github/workflows/gofmt.sh vendored Executable file
View file

@ -0,0 +1,14 @@
#!/bin/sh
if [ -z "$1" ]; then
rm -f ./gofmterr
find . -iname '*.go' ! -name '*.pb.go' -exec "$0" {} \;
[ -f ./gofmterr ] && exit 1
exit 0
fi
OUT="$(./goimports -d "$1" | awk '{printf "%s%%0A",$0}')"
if [ -n "$OUT" ]; then
echo "::error file=$1::$OUT"
touch ./gofmterr
fi

39
.github/workflows/gofmt.yml vendored Normal file
View file

@ -0,0 +1,39 @@
name: gofmt
on:
push:
branches:
- main
pull_request:
paths:
- ".github/workflows/gofmt.yml"
- ".github/workflows/gofmt.sh"
- "**.go"
jobs:
gofmt:
name: Run gofmt
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.22
uses: actions/setup-go@v2
with:
go-version: '1.22'
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-gofmt1.22-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-gofmt1.22-
- name: Install goimports
run: |
go get golang.org/x/tools/cmd/goimports
go build golang.org/x/tools/cmd/goimports
- name: gofmt
run: $GITHUB_WORKSPACE/.github/workflows/gofmt.sh

50
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Build and test
on:
push:
branches:
- main
pull_request:
paths:
- ".github/workflows/test.yml"
- "**Makefile"
- "**.go"
- "**.proto"
- "go.mod"
- "go.sum"
jobs:
test-linux:
name: Build and test on Ubuntu
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.22
uses: actions/setup-go@v2
with:
go-version: '1.22'
id: go
- name: Check out code
uses: actions/checkout@v3
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go1.22-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go1.22-
- name: Run unit tests
run: make testvv
env:
TEST_FLAGS: -race
- name: Report failures to CT129
if: ${{ always() && github.ref == 'refs/heads/main' }}
uses: ravsamhq/notify-ct129-action@v2
with:
notification_title: "It seems that my sole purpose in this dismal existence is to spread the gloomy news of broken code and shattered dreams."
footer: "I think you ought to know I'm feeling very depressed."
status: ${{ job.status }}
notify_when: 'failure'
env:
CT129_WEBHOOK_URL: ${{ secrets.CT129_REPORTING_WEBHOOK }}

22
Makefile Normal file
View file

@ -0,0 +1,22 @@
vet:
go vet -v ./...
fmt:
go fmt ./...
test:
$(TEST_ENV) go test $(TEST_FLAGS) $(shell go list ./...)
testv: TEST_FLAGS += -v
testv: test
testvv: TEST_ENV += TEST_LOGS=1
testvv: testv
testvvv: TEST_ENV += TEST_LOGS=2
testvvv: testv
testvvvv: TEST_ENV += TEST_LOGS=3
testvvvv: testv
.PHONY: vet fmt test testv testvv testvvv testvvvv

30
apiutil.go Normal file
View file

@ -0,0 +1,30 @@
package cmapi
import (
"fmt"
"gopkg.in/yaml.v2"
)
// InsertConfigPrivateKey takes a Cmesh YAML and a Cmesh PEM-formatted private key, and inserts the private key into
// the config, overwriting any previous value stored in the config.
func InsertConfigPrivateKey(config []byte, privkey []byte) ([]byte, error) {
var y map[interface{}]interface{}
if err := yaml.Unmarshal(config, &y); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %s", err)
}
_, ok := y["pki"]
if !ok {
return nil, fmt.Errorf("config is missing expected pki section")
}
_, ok = y["pki"].(map[interface{}]interface{})
if !ok {
return nil, fmt.Errorf("config has unexpected value for pki section")
}
y["pki"].(map[interface{}]interface{})["key"] = string(privkey)
return yaml.Marshal(y)
}

25
apiutil_test.go Normal file
View file

@ -0,0 +1,25 @@
package cmapi
import (
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v2"
)
func TestInsertConfigPrivateKey(t *testing.T) {
cfg, err := InsertConfigPrivateKey([]byte(`
pki: {}
`), []byte("foobar"))
require.NoError(t, err)
var y map[string]interface{}
err = yaml.Unmarshal(cfg, &y)
require.NoError(t, err)
require.Equal(t, "foobar", y["pki"].(map[interface{}]interface{})["key"])
cfg, err = InsertConfigPrivateKey([]byte(``), []byte("foobar"))
require.Error(t, err)
}

536
client.go Normal file
View file

@ -0,0 +1,536 @@
// Package cmapi handles communication with the CT129 Solution cloud API server.
package cmapi
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sync/atomic"
"time"
"git.ct129.com/VPN/cmapi/keys"
"git.ct129.com/VPN/cmapi/message"
"github.com/sirupsen/logrus"
)
// Client communicates with the API server.
type Client struct {
dnServer string
client *http.Client
streamingClient *http.Client
}
// NewClient returns new Client configured with the given useragent.
// It also supports reading Proxy information from the environment.
func NewClient(useragent string, dnServer string) *Client {
return &Client{
client: &http.Client{
Timeout: 2 * time.Minute,
Transport: &uaTransport{
T: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSHandshakeTimeout: 10 * time.Second,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
}).DialContext,
},
useragent: useragent,
},
},
streamingClient: &http.Client{
Timeout: 15 * time.Minute,
Transport: &uaTransport{
T: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSHandshakeTimeout: 10 * time.Second,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
}).DialContext,
},
useragent: useragent,
},
},
dnServer: dnServer,
}
}
// APIError wraps an error and contains the RequestID from the X-Request-ID
// header of an API response. ReqID defaults to empty string if the header is
// not in the response.
type APIError struct {
e error
ReqID string
}
func (e *APIError) Error() string {
return e.e.Error()
}
func (e *APIError) Unwrap() error {
return e.e
}
type InvalidCredentialsError struct{}
func (e InvalidCredentialsError) Error() string {
return "invalid credentials"
}
type EnrollMeta struct {
OrganizationID string
OrganizationName string
}
// Enroll issues an enrollment request against the REST API using the given enrollment code, passing along a locally
// generated DH X25519 public key to be signed by the CA, and an Ed 25519 public key for future API call authentication.
// On success it returns the Cmesh config generated by the server, a Cmesh private key PEM to be inserted into the
// config (see api.InsertConfigPrivateKey), credentials to be used in DNClient API requests, and a meta object
// containing organization info.
func (c *Client) Enroll(ctx context.Context, logger logrus.FieldLogger, code string) ([]byte, []byte, *keys.Credentials, *EnrollMeta, error) {
logger.WithFields(logrus.Fields{"server": c.dnServer}).Debug("Making enrollment request to API")
// Generate newKeys for the enrollment request
newKeys, err := keys.New()
if err != nil {
return nil, nil, nil, nil, err
}
hostEd25519PublicKeyPEM, err := newKeys.HostEd25519PublicKey.MarshalPEM()
if err != nil {
return nil, nil, nil, nil, err
}
hostP256PublicKeyPEM, err := newKeys.HostP256PublicKey.MarshalPEM()
if err != nil {
return nil, nil, nil, nil, err
}
// Make a request to the API with the enrollment code
jv, err := json.Marshal(message.EnrollRequest{
Code: code,
CmeshPubkeyX25519: newKeys.CmeshX25519PublicKeyPEM,
HostPubkeyEd25519: hostEd25519PublicKeyPEM,
CmeshPubkeyP256: newKeys.CmeshP256PublicKeyPEM,
HostPubkeyP256: hostP256PublicKeyPEM,
Timestamp: time.Now(),
})
if err != nil {
return nil, nil, nil, nil, err
}
enrollURL, err := url.JoinPath(c.dnServer, message.EnrollEndpoint)
if err != nil {
return nil, nil, nil, nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", enrollURL, bytes.NewBuffer(jv))
if err != nil {
return nil, nil, nil, nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, nil, nil, nil, err
}
defer resp.Body.Close()
// Log the request ID returned from the server
reqID := resp.Header.Get("X-Request-ID")
l := logger.WithFields(logrus.Fields{"statusCode": resp.StatusCode, "reqID": reqID})
if resp.StatusCode == http.StatusOK {
l.Info("Enrollment request returned success code")
} else {
l.Error("Enrollment request returned error code")
}
// Decode the response
r := message.EnrollResponse{}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, nil, nil, &APIError{e: fmt.Errorf("error reading response body: %s", err), ReqID: reqID}
}
if err := json.Unmarshal(b, &r); err != nil {
return nil, nil, nil, nil, &APIError{e: fmt.Errorf("error decoding JSON response: %s\nbody: %s", err, b), ReqID: reqID}
}
// Check for any errors returned by the API
if err := r.Errors.ToError(); err != nil {
return nil, nil, nil, nil, &APIError{e: fmt.Errorf("unexpected error during enrollment: %v", err), ReqID: reqID}
}
meta := &EnrollMeta{
OrganizationID: r.Data.Organization.ID,
OrganizationName: r.Data.Organization.Name,
}
// Determine the private keys to save based on the network curve type
var privkeyPEM []byte
var privkey keys.PrivateKey
switch r.Data.Network.Curve {
case message.NetworkCurve25519:
privkeyPEM = newKeys.CmeshX25519PrivateKeyPEM
privkey = newKeys.HostEd25519PrivateKey
case message.NetworkCurveP256:
privkeyPEM = newKeys.CmeshP256PrivateKeyPEM
privkey = newKeys.HostP256PrivateKey
default:
return nil, nil, nil, nil, &APIError{e: fmt.Errorf("unsupported curve type: %s", r.Data.Network.Curve), ReqID: reqID}
}
trustedKeys, err := keys.TrustedKeysFromPEM(r.Data.TrustedKeys)
if err != nil {
return nil, nil, nil, nil, &APIError{e: fmt.Errorf("failed to load trusted keys from bundle: %s", err), ReqID: reqID}
}
creds := &keys.Credentials{
HostID: r.Data.HostID,
PrivateKey: privkey,
Counter: r.Data.Counter,
TrustedKeys: trustedKeys,
}
return r.Data.Config, privkeyPEM, creds, meta, nil
}
// CheckForUpdate sends a signed message to the DNClient API to learn if there is a new configuration available.
func (c *Client) CheckForUpdate(ctx context.Context, creds keys.Credentials) (bool, error) {
respBody, err := c.postDNClient(ctx, message.CheckForUpdate, nil, creds.HostID, creds.Counter, creds.PrivateKey)
if err != nil {
return false, fmt.Errorf("failed to post message to dnclient api: %w", err)
}
result := message.CheckForUpdateResponseWrapper{}
err = json.Unmarshal(respBody, &result)
if err != nil {
return false, fmt.Errorf("failed to interpret API response: %s", err)
}
return result.Data.UpdateAvailable, nil
}
// LongPollWait sends a signed message to a DNClient API endpoint that will block, returning only
// if there is an action the client should take before the timeout (config updates, debug commands)
func (c *Client) LongPollWait(ctx context.Context, creds keys.Credentials, supportedActions []string) (*message.LongPollWaitResponse, error) {
value, err := json.Marshal(message.LongPollWaitRequest{
SupportedActions: supportedActions,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal DNClient message: %s", err)
}
respBody, err := c.postDNClient(ctx, message.LongPollWait, value, creds.HostID, creds.Counter, creds.PrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to post message to dnclient api: %w", err)
}
result := message.LongPollWaitResponseWrapper{}
err = json.Unmarshal(respBody, &result)
if err != nil {
return nil, fmt.Errorf("failed to interpret API response: %s", err)
}
return &result.Data, nil
}
// DoUpdate sends a signed message to the DNClient API to fetch the new configuration update. During this call new keys
// are generated both for Cmesh and DNClient API communication. If the API response is successful, the new configuration
// is returned along with the new Cmesh private key PEM and new DNClient API credentials.
//
// See cmapi.InsertConfigPrivateKey for how to insert the new Cmesh private key into the configuration.
func (c *Client) DoUpdate(ctx context.Context, creds keys.Credentials) ([]byte, []byte, *keys.Credentials, error) {
// Rotate keys
var cmeshPrivkeyPEM []byte // ECDH
var hostPrivkey keys.PrivateKey // ECDSA
newKeys, err := keys.New()
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to generate new keys: %s", err)
}
msg := message.DoUpdateRequest{
Nonce: nonce(),
}
// Set the correct keypair based on the current private key type
switch creds.PrivateKey.Unwrap().(type) {
case ed25519.PrivateKey:
hostPubkeyPEM, err := newKeys.HostEd25519PublicKey.MarshalPEM()
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to marshal Ed25519 public key: %s", err)
}
hostPrivkey = newKeys.HostEd25519PrivateKey
cmeshPrivkeyPEM = newKeys.CmeshX25519PrivateKeyPEM
msg.HostPubkeyEd25519 = hostPubkeyPEM
msg.CmeshPubkeyX25519 = newKeys.CmeshX25519PublicKeyPEM
case *ecdsa.PrivateKey:
hostPubkeyPEM, err := newKeys.HostP256PublicKey.MarshalPEM()
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to marshal P256 public key: %s", err)
}
hostPrivkey = newKeys.HostP256PrivateKey
cmeshPrivkeyPEM = newKeys.CmeshP256PrivateKeyPEM
msg.HostPubkeyP256 = hostPubkeyPEM
msg.CmeshPubkeyP256 = newKeys.CmeshP256PublicKeyPEM
}
blob, err := json.Marshal(msg)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to marshal DNClient message: %s", err)
}
// Make API call
resp, err := c.postDNClient(ctx, message.DoUpdate, blob, creds.HostID, creds.Counter, creds.PrivateKey)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to make API call to CT129 Solution: %w", err)
}
resultWrapper := message.SignedResponseWrapper{}
err = json.Unmarshal(resp, &resultWrapper)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to unmarshal signed response wrapper: %s", err)
}
// Verify the signature
valid := false
for _, caPubkey := range creds.TrustedKeys {
if caPubkey.Verify(resultWrapper.Data.Message, resultWrapper.Data.Signature) {
valid = true
break
}
}
if !valid {
return nil, nil, nil, fmt.Errorf("failed to verify signed API result")
}
// Consume the verified message
result := message.DoUpdateResponse{}
err = json.Unmarshal(resultWrapper.Data.Message, &result)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to unmarshal response (%s): %s", resultWrapper.Data.Message, err)
}
// Verify the nonce
if !bytes.Equal(result.Nonce, msg.Nonce) {
return nil, nil, nil, fmt.Errorf("nonce mismatch between request (%s) and response (%s)", msg.Nonce, result.Nonce)
}
// Verify the counter
if result.Counter <= creds.Counter {
return nil, nil, nil, fmt.Errorf("counter in request (%d) should be less than counter in response (%d)", creds.Counter, result.Counter)
}
trustedKeys, err := keys.TrustedKeysFromPEM(result.TrustedKeys)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to load trusted keys from bundle: %s", err)
}
newCreds := &keys.Credentials{
HostID: creds.HostID,
Counter: result.Counter,
PrivateKey: hostPrivkey,
TrustedKeys: trustedKeys,
}
return result.Config, cmeshPrivkeyPEM, newCreds, nil
}
func (c *Client) CommandResponse(ctx context.Context, creds keys.Credentials, responseToken string, response any) error {
value, err := json.Marshal(message.CommandResponseRequest{
ResponseToken: responseToken,
Response: response,
})
if err != nil {
return fmt.Errorf("failed to marshal DNClient message: %s", err)
}
_, err = c.postDNClient(ctx, message.CommandResponse, value, creds.HostID, creds.Counter, creds.PrivateKey)
return err
}
func (c *Client) StreamCommandResponse(ctx context.Context, creds keys.Credentials, responseToken string) (*StreamController, error) {
value, err := json.Marshal(message.CommandResponseRequest{
ResponseToken: responseToken,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal DNClient message: %s", err)
}
return c.streamingPostDNClient(ctx, message.CommandResponse, value, creds.HostID, creds.Counter, creds.PrivateKey)
}
// streamingPostDNClient wraps and signs the given dnclientRequestWrapper message, and makes a streaming API call.
// On success, it returns a StreamController to interact with the request. On error, the error is returned.
func (c *Client) streamingPostDNClient(ctx context.Context, reqType string, value []byte, hostID string, counter uint, privkey keys.PrivateKey) (*StreamController, error) {
pr, pw := io.Pipe()
postBody, err := SignRequestV1(reqType, value, hostID, counter, privkey)
if err != nil {
return nil, err
}
pbb := bytes.NewBuffer(postBody)
endpointV1URL, err := url.JoinPath(c.dnServer, message.EndpointV1)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", endpointV1URL, io.MultiReader(pbb, pr))
if err != nil {
return nil, err
}
done := make(chan struct{})
sc := &StreamController{w: pw, done: done}
go func() {
defer close(done)
resp, err := c.streamingClient.Do(req)
if err != nil {
sc.err.Store(fmt.Errorf("failed to call dnclient endpoint: %w", err))
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
sc.err.Store(fmt.Errorf("failed to read the response body: %s", err))
}
switch resp.StatusCode {
case http.StatusOK:
sc.respBytes = respBody
case http.StatusUnauthorized:
sc.err.Store(InvalidCredentialsError{})
default:
var errors struct {
Errors message.APIErrors
}
if err := json.Unmarshal(respBody, &errors); err != nil {
sc.err.Store(fmt.Errorf("dnclient endpoint returned bad status code '%d', body: %s", resp.StatusCode, respBody))
} else {
sc.err.Store(errors.Errors.ToError())
}
}
}()
return sc, nil
}
// postDNClient wraps and signs the given dnclientRequestWrapper message, and makes the API call.
// On success, it returns the response message body. On error, the error is returned.
func (c *Client) postDNClient(ctx context.Context, reqType string, value []byte, hostID string, counter uint, privkey keys.PrivateKey) ([]byte, error) {
postBody, err := SignRequestV1(reqType, value, hostID, counter, privkey)
if err != nil {
return nil, err
}
endpointV1URL, err := url.JoinPath(c.dnServer, message.EndpointV1)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", endpointV1URL, bytes.NewReader(postBody))
if err != nil {
return nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call dnclient endpoint: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read the response body: %s", err)
}
switch resp.StatusCode {
case http.StatusOK:
return respBody, nil
case http.StatusUnauthorized:
return nil, InvalidCredentialsError{}
default:
var errors struct {
Errors message.APIErrors
}
if err := json.Unmarshal(respBody, &errors); err != nil {
return nil, fmt.Errorf("dnclient endpoint returned bad status code '%d', body: %s", resp.StatusCode, respBody)
}
return nil, errors.Errors.ToError()
}
}
// StreamController is used for interacting with streaming requests to the API.
//
// When a streaming request is started in a background goroutine, a StreamController is returned to the caller to allow
// writing to the request body. The request will be sent when the caller closes the StreamController. The response body
// can be read by calling ResponseBytes, which will block until the response is received.
type StreamController struct {
w *io.PipeWriter
respBytes []byte
err atomic.Value
done chan struct{}
}
// Err returns any error that occurred during the streaming request. If the request was successful, Err will return nil.
// Err should be called after Close to ensure the request has completed.
func (sc *StreamController) Err() error {
err := sc.err.Load()
if err == nil {
return nil
}
return err.(error)
}
// Write implements the io.Writer interface for StreamController. It writes to the request body. If the StreamController
// has already encountered an error, it will be returned and nothing will be written.
func (sc *StreamController) Write(p []byte) (int, error) {
if sc.Err() != nil {
return 0, sc.Err()
}
n, err := sc.w.Write(p)
if err != nil {
sc.err.Store(err)
}
return n, err
}
// Close closes the StreamController, signaling that the request body is complete and the response can be read.
func (sc *StreamController) Close() error {
err := sc.w.Close()
<-sc.done
return err
}
// ResponseBytes blocks until the response is received, then returns the response body. If an error occurred during the
// request, ResponseBytes will return the error.
func (sc *StreamController) ResponseBytes() ([]byte, error) {
<-sc.done
if sc.Err() != nil {
return nil, sc.Err()
}
return sc.respBytes, nil
}
// uaTransport wraps an http.RoundTripper and sets the User-Agent header on all requests.
type uaTransport struct {
useragent string
T http.RoundTripper
}
func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", t.useragent)
return t.T.RoundTrip(req)
}
func nonce() []byte {
nonce := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
panic(err)
}
return nonce
}

831
client_test.go Normal file
View file

@ -0,0 +1,831 @@
package cmapi
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.ct129.com/VPN/cmapi/cmapitest"
"git.ct129.com/VPN/cmapi/internal/testutil"
"git.ct129.com/VPN/cmapi/keys"
"git.ct129.com/VPN/cmapi/message"
"github.com/sirupsen/logrus"
"git.ct129.com/VPN/cmesh/cert"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v2"
)
type m map[string]interface{}
func TestEnroll(t *testing.T) {
t.Parallel()
useragent := "dnclientUnitTests/1.0.0 (not a real client)"
ts := cmapitest.NewServer(useragent)
client := NewClient(useragent, ts.URL)
// attempting to defer ts.Close() will trigger early due to parallel testing - use T.Cleanup instead
t.Cleanup(func() { ts.Close() })
// Happy path enrollment
code := "abcdef"
hostID := "foobar"
orgID := "foobaz"
orgName := "foobar's foo org"
netID := "qux"
netCurve := message.NetworkCurve25519
counter := uint(5)
ca, _ := cmapitest.CmeshCACert()
caPEM, err := ca.MarshalToPEM()
require.NoError(t, err)
ts.ExpectEnrollment(code, message.NetworkCurve25519, func(req message.EnrollRequest) []byte {
cfg, err := yaml.Marshal(m{
// we need to send this or we'll get an error from the api client
"pki": m{"ca": string(caPEM)},
// here we reflect values back to the client for test purposes
"test": m{"code": req.Code, "dhPubkey": req.CmeshPubkeyX25519},
})
if err != nil {
return jsonMarshal(message.EnrollResponse{
Errors: message.APIErrors{{
Code: "ERR_FAILED_TO_MARSHAL_YAML",
Message: "failed to marshal test response config",
}},
})
}
return jsonMarshal(message.EnrollResponse{
Data: message.EnrollResponseData{
HostID: hostID,
Counter: counter,
Config: cfg,
TrustedKeys: marshalCAPublicKey(ca.Details.Curve, ca.Details.PublicKey),
Organization: message.EnrollResponseDataOrg{
ID: orgID,
Name: orgName,
},
Network: message.EnrollResponseDataNetwork{
ID: netID,
Curve: netCurve,
},
},
})
})
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
cfg, pkey, creds, meta, err := client.Enroll(ctx, testutil.NewTestLogger(), code)
require.NoError(t, err)
assert.Empty(t, ts.Errors())
assert.Equal(t, 0, ts.RequestsRemaining())
tk, err := keys.NewTrustedKey(ed25519.PublicKey(ca.Details.PublicKey))
require.NoError(t, err)
assert.Equal(t, hostID, creds.HostID)
assert.Equal(t, counter, creds.Counter)
assert.Equal(t, []keys.TrustedKey{tk}, creds.TrustedKeys)
assert.NotEmpty(t, creds.PrivateKey)
assert.NotEmpty(t, pkey)
var y struct {
PKI struct {
Key string `yaml:"key"`
} `yaml:"pki"`
Test struct {
Code string `yaml:"code"`
DHPubkey []byte `yaml:"dhPubkey"`
} `yaml:"test"`
}
err = yaml.Unmarshal(cfg, &y)
require.NoError(t, err)
_, rest, err := cert.UnmarshalX25519PublicKey(y.Test.DHPubkey)
assert.NoError(t, err)
assert.Len(t, rest, 0)
assert.Equal(t, code, y.Test.Code)
// ensure private key was not inserted into config
assert.Empty(t, y.PKI.Key)
// test meta
assert.Equal(t, orgID, meta.OrganizationID)
assert.Equal(t, orgName, meta.OrganizationName)
// Test error handling
errorMsg := "invalid enrollment code"
ts.ExpectEnrollment(code, message.NetworkCurve25519, func(req message.EnrollRequest) []byte {
return jsonMarshal(message.EnrollResponse{
Errors: message.APIErrors{{
Code: "ERR_INVALID_ENROLLMENT_CODE",
Message: errorMsg,
}},
})
})
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
cfg, pkey, creds, meta, err = client.Enroll(ctx, testutil.NewTestLogger(), code)
require.Errorf(t, err, fmt.Sprintf("unexpected error during enrollment: %s", errorMsg))
assert.Nil(t, cfg)
assert.Nil(t, pkey)
assert.Nil(t, creds)
assert.Nil(t, meta)
apiError := &APIError{}
reqIDErrPresent := errors.As(err, &apiError)
require.True(t, reqIDErrPresent)
assert.Equal(t, apiError.ReqID, "SupaDoopaRequestIdentifier")
}
func TestDoUpdate(t *testing.T) {
t.Parallel()
useragent := "testClient"
ts := cmapitest.NewServer(useragent)
t.Cleanup(func() { ts.Close() })
ca, caPrivkey := cmapitest.CmeshCACert()
caPEM, err := ca.MarshalToPEM()
require.NoError(t, err)
c := NewClient(useragent, ts.URL)
code := "foobar"
ts.ExpectEnrollment(code, message.NetworkCurve25519, func(req message.EnrollRequest) []byte {
cfg, err := yaml.Marshal(m{
// we need to send this or we'll get an error from the api client
"pki": m{"ca": string(caPEM)},
// here we reflect values back to the client for test purposes
"test": m{"code": req.Code, "dhPubkey": req.CmeshPubkeyX25519},
})
if err != nil {
return jsonMarshal(message.EnrollResponse{
Errors: message.APIErrors{{
Code: "ERR_FAILED_TO_MARSHAL_YAML",
Message: "failed to marshal test response config",
}},
})
}
return jsonMarshal(message.EnrollResponse{
Data: message.EnrollResponseData{
HostID: "foobar",
Counter: 1,
Config: cfg,
TrustedKeys: marshalCAPublicKey(ca.Details.Curve, ca.Details.PublicKey),
Organization: message.EnrollResponseDataOrg{
ID: "foobaz",
Name: "foobar's foo org",
},
Network: message.EnrollResponseDataNetwork{
ID: "qux",
Curve: message.NetworkCurve25519,
},
},
})
})
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
config, pkey, creds, _, err := c.Enroll(ctx, testutil.NewTestLogger(), "foobar")
require.NoError(t, err)
// convert privkey to private key
pubkey, err := keys.MarshalHostEd25519PublicKey(creds.PrivateKey.Public().Unwrap().(ed25519.PublicKey))
require.NoError(t, err)
// make sure all credential values were set
assert.NotEmpty(t, creds.HostID)
assert.NotEmpty(t, creds.PrivateKey)
assert.NotEmpty(t, creds.TrustedKeys)
assert.NotEmpty(t, creds.Counter)
// make sure we got a config back
assert.NotEmpty(t, config)
assert.NotEmpty(t, pkey)
// Invalid request signature should return a specific error
ts.ExpectRequest(message.CheckForUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
return []byte("")
})
// Create a new, invalid requesting authentication key
nk, err := keys.New()
require.NoError(t, err)
invalidCreds := keys.Credentials{
HostID: creds.HostID,
PrivateKey: nk.HostEd25519PrivateKey,
Counter: creds.Counter,
TrustedKeys: creds.TrustedKeys,
}
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
_, err = c.CheckForUpdate(ctx, invalidCreds)
assert.Error(t, err)
invalidCredsErrorType := InvalidCredentialsError{}
assert.ErrorAs(t, err, &invalidCredsErrorType)
serverErrs := ts.Errors() // This consumes/resets the server errors
require.Len(t, serverErrs, 1)
// Invalid signature
ts.ExpectRequest(message.DoUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
newConfigResponse := message.DoUpdateResponse{
Config: cmapitest.CmeshCfg(caPEM),
Counter: 2,
Nonce: cmapitest.GetNonce(r),
}
rawRes := jsonMarshal(newConfigResponse)
nk, err := keys.New()
require.NoError(t, err)
// XXX the mock server will update the ed pubkey for us, but this is problematic because
// we are rejecting the update. reset the key
err = ts.SetEdPubkey(pubkey)
require.NoError(t, err)
sig, err := nk.HostEd25519PrivateKey.Sign(rawRes)
require.NoError(t, err)
return jsonMarshal(message.SignedResponseWrapper{
Data: message.SignedResponse{
Version: 1,
Message: rawRes,
Signature: sig,
},
})
})
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
cfg, pkey, newCreds, err := c.DoUpdate(ctx, *creds)
require.Error(t, err)
assert.Empty(t, ts.Errors())
assert.Equal(t, 0, ts.RequestsRemaining())
require.Nil(t, newCreds)
require.Nil(t, cfg)
require.Nil(t, pkey)
// Invalid counter
ts.ExpectRequest(message.DoUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
newConfigResponse := message.DoUpdateResponse{
Config: cmapitest.CmeshCfg(caPEM),
Counter: 0,
Nonce: cmapitest.GetNonce(r),
}
rawRes := jsonMarshal(newConfigResponse)
// XXX the mock server will update the ed pubkey for us, but this is problematic because
// we are rejecting the update. reset the key
err := ts.SetEdPubkey(pubkey)
require.NoError(t, err)
return jsonMarshal(message.SignedResponseWrapper{
Data: message.SignedResponse{
Version: 1,
Message: rawRes,
Signature: ed25519.Sign(caPrivkey, rawRes),
},
})
})
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
cfg, pkey, newCreds, err = c.DoUpdate(ctx, *creds)
require.Error(t, err)
assert.Empty(t, ts.Errors())
assert.Equal(t, 0, ts.RequestsRemaining())
require.Nil(t, newCreds)
require.Nil(t, cfg)
require.Nil(t, pkey)
// This time sign the response with the correct CA key.
ts.ExpectRequest(message.DoUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
newConfigResponse := message.DoUpdateResponse{
Config: cmapitest.CmeshCfg(caPEM),
Counter: 3,
Nonce: cmapitest.GetNonce(r),
}
rawRes := jsonMarshal(newConfigResponse)
return jsonMarshal(message.SignedResponseWrapper{
Data: message.SignedResponse{
Version: 1,
Message: rawRes,
Signature: ed25519.Sign(caPrivkey, rawRes),
},
})
})
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
_, _, _, err = c.DoUpdate(ctx, *creds)
require.NoError(t, err)
assert.Empty(t, ts.Errors())
assert.Equal(t, 0, ts.RequestsRemaining())
}
func TestDoUpdate_P256(t *testing.T) {
t.Parallel()
useragent := "testClient"
ts := cmapitest.NewServer(useragent)
t.Cleanup(func() { ts.Close() })
ca, caPrivkey := cmapitest.CmeshCACertP256()
caPEM, err := ca.MarshalToPEM()
require.NoError(t, err)
c := NewClient(useragent, ts.URL)
code := "foobar"
ts.ExpectEnrollment(code, message.NetworkCurveP256, func(req message.EnrollRequest) []byte {
cfg, err := yaml.Marshal(m{
// we need to send this or we'll get an error from the api client
"pki": m{"ca": string(caPEM)},
// here we reflect values back to the client for test purposes
"test": m{"code": req.Code, "p256Pubkey": req.CmeshPubkeyP256},
})
if err != nil {
return jsonMarshal(message.EnrollResponse{
Errors: message.APIErrors{{
Code: "ERR_FAILED_TO_MARSHAL_YAML",
Message: "failed to marshal test response config",
}},
})
}
return jsonMarshal(message.EnrollResponse{
Data: message.EnrollResponseData{
HostID: "foobar",
Counter: 1,
Config: cfg,
TrustedKeys: marshalCAPublicKey(ca.Details.Curve, ca.Details.PublicKey),
Organization: message.EnrollResponseDataOrg{
ID: "foobaz",
Name: "foobar's foo org",
},
Network: message.EnrollResponseDataNetwork{
ID: "qux",
Curve: message.NetworkCurveP256,
},
},
})
})
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
config, pkey, creds, _, err := c.Enroll(ctx, testutil.NewTestLogger(), "foobar")
require.NoError(t, err)
// convert privkey to private key
pubkey, err := keys.MarshalHostP256PublicKey(creds.PrivateKey.Public().Unwrap().(*ecdsa.PublicKey))
require.NoError(t, err)
// make sure all credential values were set
assert.NotEmpty(t, creds.HostID)
assert.NotEmpty(t, creds.PrivateKey)
assert.NotEmpty(t, creds.TrustedKeys)
assert.NotEmpty(t, creds.Counter)
// make sure we got a config back
assert.NotEmpty(t, config)
assert.NotEmpty(t, pkey)
// Invalid request signature should return a specific error
ts.ExpectRequest(message.CheckForUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
return []byte("")
})
// Create a new, invalid requesting authentication key
nk, err := keys.New()
require.NoError(t, err)
invalidCreds := keys.Credentials{
HostID: creds.HostID,
PrivateKey: nk.HostP256PrivateKey,
Counter: creds.Counter,
TrustedKeys: creds.TrustedKeys,
}
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
_, err = c.CheckForUpdate(ctx, invalidCreds)
assert.Error(t, err)
invalidCredsErrorType := InvalidCredentialsError{}
assert.ErrorAs(t, err, &invalidCredsErrorType)
serverErrs := ts.Errors() // This consumes/resets the server errors
require.Len(t, serverErrs, 1)
// Invalid signature
ts.ExpectRequest(message.DoUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
newConfigResponse := message.DoUpdateResponse{
Config: cmapitest.CmeshCfg(caPEM),
Counter: 2,
Nonce: cmapitest.GetNonce(r),
}
rawRes := jsonMarshal(newConfigResponse)
nk, err := keys.New()
require.NoError(t, err)
// XXX the mock server will update the ed pubkey for us, but this is problematic because
// we are rejecting the update. reset the key
err = ts.SetP256Pubkey(pubkey)
require.NoError(t, err)
sig, err := nk.HostP256PrivateKey.Sign(rawRes)
if err != nil {
return jsonMarshal(message.EnrollResponse{
Errors: message.APIErrors{{
Code: "ERR_FAILED_TO_SIGN_MESSAGE",
Message: "failed to sign message",
}},
})
}
return jsonMarshal(message.SignedResponseWrapper{
Data: message.SignedResponse{
Version: 1,
Message: rawRes,
Signature: sig,
},
})
})
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
cfg, pkey, newCreds, err := c.DoUpdate(ctx, *creds)
require.Error(t, err)
assert.Empty(t, ts.Errors())
assert.Equal(t, 0, ts.RequestsRemaining())
require.Nil(t, newCreds)
require.Nil(t, cfg)
require.Nil(t, pkey)
// Invalid counter
ts.ExpectRequest(message.DoUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
newConfigResponse := message.DoUpdateResponse{
Config: cmapitest.CmeshCfg(caPEM),
Counter: 0,
Nonce: cmapitest.GetNonce(r),
}
rawRes := jsonMarshal(newConfigResponse)
// XXX the mock server will update the host pubkey for us, but this is problematic because
// we are rejecting the update. reset the key
err := ts.SetP256Pubkey(pubkey)
require.NoError(t, err)
hashed := sha256.Sum256(rawRes)
sig, err := ecdsa.SignASN1(rand.Reader, caPrivkey, hashed[:])
if err != nil {
return jsonMarshal(message.EnrollResponse{
Errors: message.APIErrors{{
Code: "ERR_FAILED_TO_SIGN_MESSAGE",
Message: "failed to sign message",
}},
})
}
return jsonMarshal(message.SignedResponseWrapper{
Data: message.SignedResponse{
Version: 1,
Message: rawRes,
Signature: sig,
},
})
})
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
cfg, pkey, newCreds, err = c.DoUpdate(ctx, *creds)
require.Error(t, err)
assert.Empty(t, ts.Errors())
assert.Equal(t, 0, ts.RequestsRemaining())
require.Nil(t, newCreds)
require.Nil(t, cfg)
require.Nil(t, pkey)
// This time sign the response with the correct CA key.
ts.ExpectRequest(message.DoUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
newConfigResponse := message.DoUpdateResponse{
Config: cmapitest.CmeshCfg(caPEM),
Counter: 3,
Nonce: cmapitest.GetNonce(r),
}
rawRes := jsonMarshal(newConfigResponse)
hashed := sha256.Sum256(rawRes)
sig, err := ecdsa.SignASN1(rand.Reader, caPrivkey, hashed[:])
if err != nil {
return jsonMarshal(message.EnrollResponse{
Errors: message.APIErrors{{
Code: "ERR_FAILED_TO_SIGN_MESSAGE",
Message: "failed to sign message",
}},
})
}
return jsonMarshal(message.SignedResponseWrapper{
Data: message.SignedResponse{
Version: 1,
Message: rawRes,
Signature: sig,
},
})
})
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
_, _, _, err = c.DoUpdate(ctx, *creds)
require.NoError(t, err)
assert.Empty(t, ts.Errors())
assert.Equal(t, 0, ts.RequestsRemaining())
}
func TestCommandResponse(t *testing.T) {
t.Parallel()
useragent := "testClient"
ts := cmapitest.NewServer(useragent)
t.Cleanup(func() { ts.Close() })
ca, _ := cmapitest.CmeshCACert()
caPEM, err := ca.MarshalToPEM()
require.NoError(t, err)
c := NewClient(useragent, ts.URL)
code := "foobar"
ts.ExpectEnrollment(code, message.NetworkCurve25519, func(req message.EnrollRequest) []byte {
cfg, err := yaml.Marshal(m{
// we need to send this or we'll get an error from the api client
"pki": m{"ca": string(caPEM)},
// here we reflect values back to the client for test purposes
"test": m{"code": req.Code, "dhPubkey": req.CmeshPubkeyX25519},
})
if err != nil {
return jsonMarshal(message.EnrollResponse{
Errors: message.APIErrors{{
Code: "ERR_FAILED_TO_MARSHAL_YAML",
Message: "failed to marshal test response config",
}},
})
}
return jsonMarshal(message.EnrollResponse{
Data: message.EnrollResponseData{
HostID: "foobar",
Counter: 1,
Config: cfg,
TrustedKeys: marshalCAPublicKey(ca.Details.Curve, ca.Details.PublicKey),
Organization: message.EnrollResponseDataOrg{
ID: "foobaz",
Name: "foobar's foo org",
},
Network: message.EnrollResponseDataNetwork{
ID: "qux",
Curve: message.NetworkCurve25519,
},
},
})
})
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
config, pkey, creds, _, err := c.Enroll(ctx, testutil.NewTestLogger(), "foobar")
require.NoError(t, err)
// make sure all credential values were set
assert.NotEmpty(t, creds.HostID)
assert.NotEmpty(t, creds.PrivateKey)
assert.NotEmpty(t, creds.TrustedKeys)
assert.NotEmpty(t, creds.Counter)
// make sure we got a config back
assert.NotEmpty(t, config)
assert.NotEmpty(t, pkey)
// This time sign the response with the correct CA key.
responseToken := "abc123"
res := map[string]any{"msg": "Hello, world!"}
ts.ExpectRequest(message.CommandResponse, http.StatusOK, func(r message.RequestWrapper) []byte {
var val map[string]any
err := json.Unmarshal(r.Value, &val)
require.NoError(t, err)
require.Contains(t, val, "responseToken")
require.Equal(t, responseToken, val["responseToken"])
require.Contains(t, val, "response")
require.Equal(t, res, val["response"])
return jsonMarshal(struct{}{})
})
err = c.CommandResponse(context.Background(), *creds, responseToken, res)
require.NoError(t, err)
// Test error handling
errorMsg := "sample error"
ts.ExpectRequest(message.CommandResponse, http.StatusBadRequest, func(r message.RequestWrapper) []byte {
return jsonMarshal(message.EnrollResponse{
Errors: message.APIErrors{{
Code: "ERR_INVALID_VALUE",
Message: errorMsg,
}},
})
})
err = c.CommandResponse(context.Background(), *creds, "responseToken", map[string]any{"msg": "Hello, world!"})
require.Error(t, err)
}
func TestStreamCommandResponse(t *testing.T) {
t.Parallel()
useragent := "testClient"
ts := cmapitest.NewServer(useragent)
t.Cleanup(func() { ts.Close() })
ca, _ := cmapitest.CmeshCACert()
caPEM, err := ca.MarshalToPEM()
require.NoError(t, err)
c := NewClient(useragent, ts.URL)
code := "foobar"
ts.ExpectEnrollment(code, message.NetworkCurve25519, func(req message.EnrollRequest) []byte {
cfg, err := yaml.Marshal(m{
// we need to send this or we'll get an error from the api client
"pki": m{"ca": string(caPEM)},
// here we reflect values back to the client for test purposes
"test": m{"code": req.Code, "dhPubkey": req.CmeshPubkeyX25519},
})
if err != nil {
return jsonMarshal(message.EnrollResponse{
Errors: message.APIErrors{{
Code: "ERR_FAILED_TO_MARSHAL_YAML",
Message: "failed to marshal test response config",
}},
})
}
return jsonMarshal(message.EnrollResponse{
Data: message.EnrollResponseData{
HostID: "foobar",
Counter: 1,
Config: cfg,
TrustedKeys: marshalCAPublicKey(ca.Details.Curve, ca.Details.PublicKey),
Organization: message.EnrollResponseDataOrg{
ID: "foobaz",
Name: "foobar's foo org",
},
Network: message.EnrollResponseDataNetwork{
ID: "qux",
Curve: message.NetworkCurve25519,
},
},
})
})
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
config, pkey, creds, _, err := c.Enroll(ctx, testutil.NewTestLogger(), "foobar")
require.NoError(t, err)
// make sure all credential values were set
assert.NotEmpty(t, creds.HostID)
assert.NotEmpty(t, creds.PrivateKey)
assert.NotEmpty(t, creds.TrustedKeys)
assert.NotEmpty(t, creds.Counter)
// make sure we got a config back
assert.NotEmpty(t, config)
assert.NotEmpty(t, pkey)
// Buffer that will store the logs sent to the service for verification
var buf bytes.Buffer
// This time sign the response with the correct CA key.
ts.ExpectStreamingRequest(message.CommandResponse, http.StatusOK, func(r message.RequestWrapper) []byte {
return jsonMarshal(struct{}{})
})
sc, err := c.StreamCommandResponse(context.Background(), *creds, "responseToken")
require.NoError(t, err)
// Configure a logger to write to a buffer and the stream
logger := logrus.New()
logger.SetFormatter(&logrus.JSONFormatter{})
logger.SetOutput(io.MultiWriter(sc, &buf))
logger.SetLevel(logrus.DebugLevel)
logger.Info("Hello, world! info!")
logger.Warn("Hello, world! warning!")
err = sc.Close()
require.NoError(t, err)
require.NoError(t, sc.Err())
require.Equal(t, buf.Bytes(), ts.LastStreamedBody())
// Test error handling
errorMsg := "sample error"
ts.ExpectStreamingRequest(message.CommandResponse, http.StatusBadRequest, func(r message.RequestWrapper) []byte {
return jsonMarshal(message.EnrollResponse{
Errors: message.APIErrors{{
Code: "ERR_INVALID_VALUE",
Message: errorMsg,
}},
})
})
buf.Reset()
sc, err = c.StreamCommandResponse(context.Background(), *creds, "responseToken")
require.NoError(t, err)
logger.SetOutput(io.MultiWriter(sc, &buf))
logger.Info("Hello, world! info!")
logger.Warn("Hello, world! warning!")
// Close shouldn't return an error - that's only if the writer fails to close
assert.NoError(t, sc.Close())
// Err should return the error from the server
assert.Error(t, sc.Err())
assert.Empty(t, ts.Errors())
assert.Equal(t, 0, ts.RequestsRemaining(), ts.ExpectedRequests())
}
func jsonMarshal(v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
func TestTimeout(t *testing.T) {
ts := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(250 * time.Millisecond)
fmt.Fprintln(w, "OK")
}))
defer ts.Close()
useragent := "TestTimeout agent"
c := NewClient(useragent, ts.URL)
// The default timeout is 1 minutes. Assert the default value.
assert.Equal(t, c.client.Timeout, 2*time.Minute)
// The default streaming timeout is 15 minutes. Assert the default value.
assert.Equal(t, c.streamingClient.Timeout, 15*time.Minute)
// Overwrite the default value with a 10 millisecond timeout for test brevity.
c.client.Timeout = 10 * time.Millisecond
// DO IT
_, err := c.client.Get(ts.URL + "/lol")
require.Error(t, err)
}
func TestOverrideTimeout(t *testing.T) {
ts := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(250 * time.Millisecond)
fmt.Fprintln(w, "OK")
}))
defer ts.Close()
useragent := "TestTimeout agent"
c := NewClient(useragent, ts.URL)
// DO IT
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
_, _, _, _, err := c.Enroll(ctx, testutil.NewTestLogger(), "ABC123")
require.ErrorIs(t, err, context.DeadlineExceeded)
}
func marshalCAPublicKey(curve cert.Curve, pubkey []byte) []byte {
switch curve {
case cert.Curve_CURVE25519:
return pem.EncodeToMemory(&pem.Block{Type: keys.CmeshEd25519PublicKeyBanner, Bytes: pubkey})
case cert.Curve_P256:
return pem.EncodeToMemory(&pem.Block{Type: keys.CmeshECDSAP256PublicKeyBanner, Bytes: pubkey})
default:
panic("unsupported curve")
}
}

493
cmapitest/cmapitest.go Normal file
View file

@ -0,0 +1,493 @@
// Package cmapitest contains utilities for testing the cmapi package. Be aware
// that any function in this package may panic on error.
package cmapitest
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"time"
"git.ct129.com/VPN/cmapi/keys"
"git.ct129.com/VPN/cmapi/message"
"git.ct129.com/VPN/cmesh/cert"
"gopkg.in/yaml.v2"
)
// m is a helper type for building out generic maps (e.g. for marshalling.)
type m map[string]interface{}
type Server struct {
*httptest.Server
errors []error
streamedBody []byte
expectedRequests []requestResponse
expectedEdPubkey ed25519.PublicKey
expectedP256Pubkey *ecdsa.PublicKey
expectedUserAgent string
// curve is set by the enroll request (which must match expectedEnrollment)
curve message.NetworkCurve
}
func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") != s.expectedUserAgent {
s.errors = append(
s.errors,
fmt.Errorf("unexpected user agent: %s, expected: %s", r.Header.Get("User-Agent"), s.expectedUserAgent),
)
}
// There are no more test cases to return, so write nothing and return
if s.RequestsRemaining() == 0 {
s.errors = append(s.errors, fmt.Errorf("unexpected request - no mock responses to return"))
http.Error(w, "unexpected request", http.StatusInternalServerError)
return
}
switch r.URL.Path {
case message.EnrollEndpoint:
w.Header().Set("X-Request-ID", "SupaDoopaRequestIdentifier")
s.handlerEnroll(w, r)
case message.EndpointV1:
s.handlerDNClient(w, r)
default:
s.errors = append(s.errors, fmt.Errorf("invalid request path %s", r.URL.Path))
http.NotFound(w, r)
}
}
func (s *Server) handlerEnroll(w http.ResponseWriter, r *http.Request) {
// Get the test case to validate
expected := s.expectedRequests[0]
s.expectedRequests = s.expectedRequests[1:]
if expected.dnclientAPI {
s.errors = append(s.errors, fmt.Errorf("unexpected enrollment request - expected dnclient API request"))
http.Error(w, "unexpected enrollment request", http.StatusInternalServerError)
return
}
res := expected.enrollRequestResponse
// read and unmarshal body
var req message.EnrollRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errors = append(s.errors, fmt.Errorf("failed to decode enroll request: %w", err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// soft failure, we can continue
if req.Timestamp.IsZero() {
s.errors = append(s.errors, fmt.Errorf("missing timestamp"))
}
if res.curve == message.NetworkCurve25519 {
if err := s.SetEdPubkey(req.HostPubkeyEd25519); err != nil {
s.errors = append(s.errors, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else if res.curve == message.NetworkCurveP256 {
if err := s.SetP256Pubkey(req.HostPubkeyP256); err != nil {
s.errors = append(s.errors, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
s.curve = res.curve
w.Write(res.response(req))
}
func (s *Server) SetCurve(curve message.NetworkCurve) {
s.curve = curve
}
func (s *Server) SetEdPubkey(edPubkeyPEM []byte) error {
// hard failure, return
edPubkey, rest, err := keys.UnmarshalHostEd25519PublicKey(edPubkeyPEM)
if err != nil {
return fmt.Errorf("failed to unmarshal ed pubkey: %w", err)
}
s.expectedEdPubkey = edPubkey
// soft failure, log it and avoid bailing the request
if len(rest) > 0 {
s.errors = append(s.errors, fmt.Errorf("unexpected trailer in ed pubkey: %s", rest))
}
return nil
}
func (s *Server) SetP256Pubkey(p256PubkeyPEM []byte) error {
// hard failure, return
pubkey, rest, err := keys.UnmarshalHostP256PublicKey(p256PubkeyPEM)
if err != nil {
return fmt.Errorf("failed to unmarshal P256 pubkey: %w", err)
}
s.expectedP256Pubkey = pubkey
// soft failure, log it and avoid bailing the request
if len(rest) > 0 {
s.errors = append(s.errors, fmt.Errorf("unexpected trailer in ed pubkey: %s", rest))
}
return nil
}
func (s *Server) handlerDNClient(w http.ResponseWriter, r *http.Request) {
// Get the test case to validate
expected := s.expectedRequests[0]
s.expectedRequests = s.expectedRequests[1:]
if !expected.dnclientAPI {
s.errors = append(s.errors, fmt.Errorf("unexpected dnclient API request - expected enrollment request"))
http.Error(w, "unexpected dnclient API request", http.StatusInternalServerError)
return
}
res := expected.dncRequestResponse
jd := json.NewDecoder(r.Body)
req := message.RequestV1{}
err := jd.Decode(&req)
if err != nil {
s.errors = append(s.errors, fmt.Errorf("failed to decode request: %w", err))
http.Error(w, "failed to decode request", http.StatusInternalServerError)
return
}
// Assert that the signature is correct
switch s.curve {
case message.NetworkCurve25519:
if !ed25519.Verify(s.expectedEdPubkey, []byte(req.Message), req.Signature) {
s.errors = append(s.errors, fmt.Errorf("invalid signature"))
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
case message.NetworkCurveP256:
// Convert the signature to a format Go understands
var esig struct {
R, S *big.Int
}
if _, err := asn1.Unmarshal(req.Signature, &esig); err != nil {
s.errors = append(s.errors, fmt.Errorf("failed to unmarshal signature: %w", err))
http.Error(w, "failed to unmarshal signature", http.StatusInternalServerError)
return
}
hashed := sha256.Sum256([]byte(req.Message))
if !ecdsa.Verify(s.expectedP256Pubkey, hashed[:], esig.R, esig.S) {
s.errors = append(s.errors, fmt.Errorf("invalid signature"))
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
default:
s.errors = append(s.errors, fmt.Errorf("invalid curve"))
http.Error(w, "invalid curve", http.StatusInternalServerError)
return
}
// Decode the signed message
decodedMsg, err := base64.StdEncoding.DecodeString(req.Message)
if err != nil {
s.errors = append(s.errors, fmt.Errorf("failed to decode request message: %w", err))
http.Error(w, "failed to decode request message", http.StatusInternalServerError)
return
}
msg := message.RequestWrapper{}
err = json.Unmarshal(decodedMsg, &msg)
if err != nil {
s.errors = append(s.errors, fmt.Errorf("failed to unmarshal request wrapper: %w", err))
http.Error(w, "failed to unmarshal request request", http.StatusInternalServerError)
return
}
// Require the expected request type, otherwise we have derailed.
if msg.Type != res.expectedType {
s.errors = append(s.errors, fmt.Errorf("%s is not expected message type %s", msg.Type, res.expectedType))
http.Error(w, fmt.Sprintf("unexpected message type %s, wanted %s", msg.Type, res.expectedType), http.StatusInternalServerError)
return
}
switch msg.Type {
case message.DoUpdate:
var updateKeys message.DoUpdateRequest
err = json.Unmarshal(msg.Value, &updateKeys)
if err != nil {
s.errors = append(s.errors, fmt.Errorf("failed to unmarshal DoUpdateRequest: %w", err))
http.Error(w, "failed to unmarshal DoUpdateRequest", http.StatusInternalServerError)
return
}
switch s.curve {
case message.NetworkCurve25519:
if err := s.SetEdPubkey(updateKeys.HostPubkeyEd25519); err != nil {
s.errors = append(s.errors, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
case message.NetworkCurveP256:
if err := s.SetP256Pubkey(updateKeys.HostPubkeyP256); err != nil {
s.errors = append(s.errors, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
default:
s.errors = append(s.errors, fmt.Errorf("invalid curve"))
http.Error(w, "invalid curve", http.StatusInternalServerError)
return
}
case message.LongPollWait:
var longPoll message.LongPollWaitRequest
err = json.Unmarshal(msg.Value, &longPoll)
if err != nil {
s.errors = append(s.errors, fmt.Errorf("failed to unmarshal LongPollWaitRequest: %w", err))
http.Error(w, "failed to unmarshal LongPollWaitRequest", http.StatusInternalServerError)
return
}
if len(longPoll.SupportedActions) == 0 {
s.errors = append(s.errors, fmt.Errorf("no supported actions"))
http.Error(w, "no supported actions", http.StatusInternalServerError)
return
}
case message.CommandResponse:
var cmdResponse message.CommandResponseRequest
err = json.Unmarshal(msg.Value, &cmdResponse)
if err != nil {
s.errors = append(s.errors, fmt.Errorf("failed to unmarshal StreamLogsRequest: %w", err))
http.Error(w, "failed to unmarshal CommandResponse", http.StatusInternalServerError)
return
}
}
if expected.isStreamingRequest {
s.streamedBody, err = io.ReadAll(io.MultiReader(jd.Buffered(), r.Body))
if err != nil {
s.errors = append(s.errors, fmt.Errorf("failed to read body: %w", err))
http.Error(w, "failed to read body", http.StatusInternalServerError)
return
}
}
// return the associated response
w.WriteHeader(res.statusCode)
w.Write(res.response(msg))
}
func (s *Server) ExpectEnrollment(code string, curve message.NetworkCurve, response func(req message.EnrollRequest) []byte) {
s.expectedRequests = append(s.expectedRequests, requestResponse{
dnclientAPI: false,
enrollRequestResponse: enrollRequestResponse{
expectedCode: code,
response: response,
curve: curve,
},
})
}
func (s *Server) ExpectRequest(msgType string, statusCode int, response func(r message.RequestWrapper) []byte) {
s.expectedRequests = append(s.expectedRequests, requestResponse{
dnclientAPI: true,
dncRequestResponse: dncRequestResponse{
statusCode: statusCode,
expectedType: msgType,
response: response,
},
})
}
func (s *Server) ExpectStreamingRequest(msgType string, statusCode int, response func(r message.RequestWrapper) []byte) {
s.expectedRequests = append(s.expectedRequests, requestResponse{
dnclientAPI: true,
isStreamingRequest: true,
dncRequestResponse: dncRequestResponse{
statusCode: statusCode,
expectedType: msgType,
response: response,
},
})
}
func (s *Server) Errors() []error {
defer func() {
s.errors = []error{}
}()
return s.errors
}
func (s *Server) RequestsRemaining() int {
return len(s.expectedRequests)
}
func (s *Server) ExpectedRequests() []requestResponse {
return s.expectedRequests
}
func (s *Server) LastStreamedBody() []byte {
return s.streamedBody
}
func NewServer(expectedUserAgent string) *Server {
s := &Server{
errors: []error{},
expectedRequests: []requestResponse{},
expectedUserAgent: expectedUserAgent,
curve: message.NetworkCurve25519, // default for legacy tests
}
ts := httptest.NewServer(http.HandlerFunc(s.handler))
s.Server = ts
return s
}
type requestResponse struct {
dnclientAPI bool
dncRequestResponse dncRequestResponse
enrollRequestResponse enrollRequestResponse
isStreamingRequest bool
}
type enrollRequestResponse struct {
expectedCode string
curve message.NetworkCurve
response func(r message.EnrollRequest) []byte
}
type dncRequestResponse struct {
expectedType string
statusCode int
response func(r message.RequestWrapper) []byte
}
func GetNonce(r message.RequestWrapper) []byte {
msg := struct{ Nonce []byte }{}
if err := json.Unmarshal(r.Value, &msg); err != nil {
panic(err)
}
return msg.Nonce
}
// CmeshCfg returns a dummy Cmesh config file, returning the marshalled cert in yaml format.
func CmeshCfg(caCert []byte) []byte {
rawConfig := m{
"pki": m{
"ca": string(caCert), // []byte will convert to a YAML list
"cert": string(caCert), // []byte will convert to a YAML list
// key will be filled in on the host
},
"static_host_map": map[string][]string{},
"punchy": m{
"punch": true,
"respond": true,
},
"lighthouse": "",
"listen": "",
"tun": m{
"dev": "cmesh99", // 99 chosen to try to avoid conflicts
"mtu": 1300,
"drop_local_broadcast": true,
"drop_multicast": true,
},
"logging": m{
"level": "info",
"format": "text",
},
"firewall": m{
"outbound": "",
"inbound": "",
},
}
cmeshCfg, err := yaml.Marshal(rawConfig)
if err != nil {
panic(err)
}
return cmeshCfg
}
func CmeshCACert() (*cert.CmeshCertificate, ed25519.PrivateKey) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
panic(err)
}
nc := &cert.CmeshCertificate{
Details: cert.CmeshCertificateDetails{
Name: "UnitTesting",
Groups: []string{"testa", "testb"},
Ips: []*net.IPNet{},
Subnets: []*net.IPNet{},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
PublicKey: pub,
IsCA: true,
},
}
err = nc.Sign(nc.Details.Curve, priv)
if err != nil {
panic(err)
}
return nc, priv
}
func CmeshCACertP256() (*cert.CmeshCertificate, *ecdsa.PrivateKey) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
// ecdh.PrivateKey lets us get at the encoded bytes, even though
// we aren't using ECDH here.
eKey, err := key.ECDH()
if err != nil {
panic(err)
}
rawPriv := eKey.Bytes()
pub := eKey.PublicKey().Bytes()
nc := &cert.CmeshCertificate{
Details: cert.CmeshCertificateDetails{
Curve: cert.Curve_P256,
Name: "UnitTesting",
Groups: []string{"testa", "testb"},
Ips: []*net.IPNet{},
Subnets: []*net.IPNet{},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
PublicKey: pub,
IsCA: true,
},
}
err = nc.Sign(nc.Details.Curve, rawPriv)
if err != nil {
panic(err)
}
return nc, key
}

1
examples/simple/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
simple

82
examples/simple/main.go Normal file
View file

@ -0,0 +1,82 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
"git.ct129.com/VPN/cmapi"
"github.com/sirupsen/logrus"
)
func main() {
server := flag.String("server", "https://cmesh.ct129.com", "API server (e.g. https://cmesh.ct129.com)")
code := flag.String("code", "", "enrollment code")
flag.Parse()
if *code == "" {
fmt.Println("-code flag must be set")
flag.Usage()
os.Exit(1)
}
logger := logrus.New()
c := cmapi.NewClient("api-example/1.0", *server)
// initial enrollment example
config, pkey, creds, meta, err := c.Enroll(context.Background(), logger, *code)
if err != nil {
logger.WithError(err).Error("Failed to enroll")
}
config, err = cmapi.InsertConfigPrivateKey(config, pkey)
if err != nil {
logger.WithError(err).Error("Failed to insert private key into config")
}
fmt.Printf(
"Host ID: %s (Org: %s, ID: %s), Counter: %d, Config:\n\n%s\n",
creds.HostID,
meta.OrganizationName,
meta.OrganizationID,
creds.Counter,
config,
)
// loop and check for updates example
for {
logger.Info("Waiting 60 seconds to check for update")
time.Sleep(60 * time.Second)
// check for an update and perform the update if available
updateAvailable, err := c.CheckForUpdate(context.Background(), *creds)
if err != nil {
logger.WithError(err).Error("Failed to check for update")
continue
}
if updateAvailable {
// be careful not to blow away creds in case err != nil
// another option is to pass credentials by reference and let DoUpdate modify the struct if successful but
// this makes it less obvious to the caller that they need to save the new credentials to disk
config, pkey, newCreds, err := c.DoUpdate(context.Background(), *creds)
if err != nil {
logger.WithError(err).Error("Failed to perform update")
continue
}
config, err = cmapi.InsertConfigPrivateKey(config, pkey)
if err != nil {
logger.WithError(err).Error("Failed to insert private key into config")
}
creds = newCreds
fmt.Printf("Counter: %d, config:\n\n%s\n", creds.Counter, config)
// XXX Now would be a good time to save both the new config and credentials to disk and reload Cmesh.
}
}
}

22
go.mod Normal file
View file

@ -0,0 +1,22 @@
module git.ct129.com/VPN/cmapi
go 1.22
require (
github.com/sirupsen/logrus v1.9.2
git.ct129.com/VPN/cmesh v1.7.1
github.com/stretchr/testify v1.8.2
golang.org/x/crypto v0.9.0
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.8.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

52
go.sum Normal file
View file

@ -0,0 +1,52 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y=
github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
git.ct129.com/VPN/cmesh v1.7.1 h1:+kzPkx9rMXJKj43N7Zcdb+ZsHAX+/u2beS7qPHbWhdw=
git.ct129.com/VPN/cmesh v1.7.1/go.mod h1:cnaoahkUipDs1vrNoIszyp0QPRIQN9Pm68ppQEW1Fhg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,37 @@
package testutil
import (
"io/ioutil"
"os"
"github.com/sirupsen/logrus"
)
// NewTestLogger returns a *logrus.Logger struct configured for testing (e.g. end-to-end tests, unit tests, etc.)
func NewTestLogger() *logrus.Logger {
l := logrus.New()
l.SetFormatter(&logrus.JSONFormatter{
DisableTimestamp: true,
FieldMap: logrus.FieldMap{
logrus.FieldKeyMsg: "message",
},
})
v := os.Getenv("TEST_LOGS")
if v == "" {
l.SetOutput(ioutil.Discard)
return l
}
switch v {
case "1":
// This is the default level but we are being explicit
l.SetLevel(logrus.InfoLevel)
case "2":
l.SetLevel(logrus.DebugLevel)
case "3":
l.SetLevel(logrus.TraceLevel)
}
return l
}

9
keys/credentials.go Normal file
View file

@ -0,0 +1,9 @@
package keys
// Credentials contains information necessary to make requests against the DNClient API.
type Credentials struct {
HostID string
PrivateKey PrivateKey
Counter uint
TrustedKeys []TrustedKey
}

180
keys/crypto.go Normal file
View file

@ -0,0 +1,180 @@
package keys
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
"git.ct129.com/VPN/cmesh/cert"
"golang.org/x/crypto/curve25519"
)
// Ed25519PublicKey is a wrapper around an Ed25519 public key that implements
// the PublicKey interface.
type Ed25519PublicKey struct {
ed25519.PublicKey
}
func (k Ed25519PublicKey) Unwrap() interface{} {
return k.PublicKey
}
func (k Ed25519PublicKey) MarshalPEM() ([]byte, error) {
return MarshalHostEd25519PublicKey(k.PublicKey)
}
// P256PublicKey is a wrapper around an ECDSA public key that implements the
// PublicKey interface.
type P256PublicKey struct {
*ecdsa.PublicKey
}
func (k P256PublicKey) Unwrap() interface{} {
return k.PublicKey
}
func (k P256PublicKey) MarshalPEM() ([]byte, error) {
return MarshalHostP256PublicKey(k.PublicKey)
}
// Ed25519PrivateKey is a wrapper around an Ed25519 private key that implements
// the PrivateKey interface.
type Ed25519PrivateKey struct {
ed25519.PrivateKey
}
func (k Ed25519PrivateKey) Sign(msg []byte) ([]byte, error) {
return ed25519.Sign(k.PrivateKey, msg), nil
}
func (k Ed25519PrivateKey) Unwrap() interface{} {
return k.PrivateKey
}
func (k Ed25519PrivateKey) MarshalPEM() ([]byte, error) {
return MarshalHostEd25519PrivateKey(k.PrivateKey)
}
func (k Ed25519PrivateKey) Public() PublicKey {
return Ed25519PublicKey{k.PrivateKey.Public().(ed25519.PublicKey)}
}
// P256PrivateKey is a wrapper around an ECDSA private key that implements the
// PrivateKey interface.
type P256PrivateKey struct {
*ecdsa.PrivateKey
}
func (k P256PrivateKey) Sign(msg []byte) ([]byte, error) {
hashed := sha256.Sum256(msg)
return ecdsa.SignASN1(rand.Reader, k.PrivateKey, hashed[:])
}
func (k P256PrivateKey) Unwrap() interface{} {
return k.PrivateKey
}
func (k P256PrivateKey) MarshalPEM() ([]byte, error) {
return MarshalHostP256PrivateKey(k.PrivateKey)
}
func (k P256PrivateKey) Public() PublicKey {
return P256PublicKey{k.PrivateKey.Public().(*ecdsa.PublicKey)}
}
// newKeys25519 returns a new set of Cmesh (Diffie-Hellman) keys and a new set of Ed25519 (request signing) keys.
func newKeys25519() ([]byte, []byte, ed25519.PublicKey, ed25519.PrivateKey, error) {
dhPubkeyPEM, dhPrivkeyPEM, err := newCmeshX25519KeypairPEM()
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to generate Cmesh keypair: %s", err)
}
edPubkey, edPrivkey, err := newEd25519Keypair()
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to generate Ed25519 keypair: %s", err)
}
return dhPubkeyPEM, dhPrivkeyPEM, edPubkey, edPrivkey, nil
}
// newKeysP256 returns a new set of Cmesh (Diffie-Hellman) ECDH P256 keys and a new set of ECDSA (request signing) keys.
func newKeysP256() ([]byte, []byte, *ecdsa.PublicKey, *ecdsa.PrivateKey, error) {
ecdhPubkeyPEM, ecdhPrivkeyPEM, err := newCmeshP256KeypairPEM()
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to generate Cmesh keypair: %s", err)
}
ecdsaPubkey, ecdsaPrivkey, err := newP256Keypair()
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to generate Ed25519 keypair: %s", err)
}
return ecdhPubkeyPEM, ecdhPrivkeyPEM, ecdsaPubkey, ecdsaPrivkey, nil
}
// newX25519Keypair create a pair of X25519 public key and private key and returns them, in that order.
func newX25519Keypair() ([]byte, []byte, error) {
var privkey = make([]byte, curve25519.ScalarSize)
if _, err := io.ReadFull(rand.Reader, privkey); err != nil {
return nil, nil, err
}
pubkey, err := curve25519.X25519(privkey, curve25519.Basepoint)
if err != nil {
return nil, nil, err
}
return pubkey, privkey, nil
}
// newEd25519Keypair returns a new Ed25519 (pubkey, privkey) pair usable for signing.
func newEd25519Keypair() (ed25519.PublicKey, ed25519.PrivateKey, error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
return pub, priv, nil
}
// newP256Keypair create a pair of P256 public key and private key and returns them, in that order.
func newP256Keypair() (*ecdsa.PublicKey, *ecdsa.PrivateKey, error) {
privkey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("error while generating ecdsa keys: %s", err)
}
return privkey.Public().(*ecdsa.PublicKey), privkey, nil
}
// newCmeshX25519KeypairPEM returns a new Cmesh keypair (X25519) in PEM format.
func newCmeshX25519KeypairPEM() ([]byte, []byte, error) {
pubkey, privkey, err := newX25519Keypair()
if err != nil {
return nil, nil, err
}
pubkey, privkey = cert.MarshalX25519PublicKey(pubkey), cert.MarshalX25519PrivateKey(privkey)
return pubkey, privkey, nil
}
// newCmeshP256KeypairPEM returns a new Cmesh keypair (P256) in PEM format.
func newCmeshP256KeypairPEM() ([]byte, []byte, error) {
rawPrivkey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("error while generating ecdsa keys: %s", err)
}
ecdhPrivkey, err := rawPrivkey.ECDH()
if err != nil {
return nil, nil, err
}
pubkey := cert.MarshalPublicKey(cert.Curve_P256, ecdhPrivkey.PublicKey().Bytes())
privkey := cert.MarshalPrivateKey(cert.Curve_P256, ecdhPrivkey.Bytes())
return pubkey, privkey, nil
}

119
keys/keys.go Normal file
View file

@ -0,0 +1,119 @@
package keys
import (
"crypto/ecdsa"
"crypto/ed25519"
"fmt"
)
// Keys contains a set of P256 and X25519/Ed25519 keys. Only one set is used,
// depending on the network the host is enrolled in. At the time of enrollment
// clients do not know which curve the network uses, so both keys must be
// generated.
//
// Cmesh keys are returned in PEM format as the public keys is sent off to the
// DN API and the private Cmesh key is written to disk and parsed by the
// Cmesh library. The host keys will be used to sign requests.
type Keys struct {
// 25519 Curve
CmeshX25519PublicKeyPEM []byte // ECDH (Cmesh)
CmeshX25519PrivateKeyPEM []byte // ECDH (Cmesh)
HostEd25519PublicKey PublicKey // EdDSA (DN API)
HostEd25519PrivateKey PrivateKey // EdDSA (DN API)
// P256 Curve
CmeshP256PublicKeyPEM []byte // ECDH (Cmesh)
CmeshP256PrivateKeyPEM []byte // ECDH (Cmesh)
HostP256PublicKey PublicKey // ECDSA (DN API)
HostP256PrivateKey PrivateKey // ECDSA (DN API)
}
func New() (*Keys, error) {
x25519PublicKeyPEM, x25519PrivateKeyPEM, ed25519PublicKey, ed25519PrivateKey, err := newKeys25519()
if err != nil {
return nil, err
}
ed25519PublicKeyI, err := NewPublicKey(ed25519PublicKey)
if err != nil {
return nil, err
}
ed25519PrivateKeyI, err := NewPrivateKey(ed25519PrivateKey)
if err != nil {
return nil, err
}
ecdhP256PublicKeyPEM, ecdhP256PrivateKeyPEM, ecdsaP256PublicKey, ecdsaP256PrivateKey, err := newKeysP256()
if err != nil {
return nil, err
}
ecdsaP256PublicKeyI, err := NewPublicKey(ecdsaP256PublicKey)
if err != nil {
return nil, err
}
ecdsaP256PrivateKeyI, err := NewPrivateKey(ecdsaP256PrivateKey)
if err != nil {
return nil, err
}
return &Keys{
CmeshX25519PublicKeyPEM: x25519PublicKeyPEM,
CmeshX25519PrivateKeyPEM: x25519PrivateKeyPEM,
HostEd25519PublicKey: ed25519PublicKeyI,
HostEd25519PrivateKey: ed25519PrivateKeyI,
CmeshP256PublicKeyPEM: ecdhP256PublicKeyPEM,
CmeshP256PrivateKeyPEM: ecdhP256PrivateKeyPEM,
HostP256PublicKey: ecdsaP256PublicKeyI,
HostP256PrivateKey: ecdsaP256PrivateKeyI,
}, nil
}
// PublicKey is a wrapper around public keys.
type PublicKey interface {
// Unwrap returns the internal public key object (e.g. *ecdsa.PublicKey or ed25519.PublicKey.)
Unwrap() interface{}
// MarshalPEM returns the public key in PEM format.
MarshalPEM() ([]byte, error)
}
func NewPublicKey(k any) (PublicKey, error) {
switch k := k.(type) {
case *ecdsa.PublicKey:
return P256PublicKey{k}, nil
case ed25519.PublicKey:
return Ed25519PublicKey{k}, nil
default:
return nil, fmt.Errorf("unsupported public key type: %T", k)
}
}
// PrivateKey is an interface used to generically sign messages regardless of
// the network curve (P256/25519.)
type PrivateKey interface {
// Sign signs the message with the private key and returns the signature.
Sign(msg []byte) ([]byte, error)
// Unwrap returns the internal private key object (e.g. *ecdsa.PrivateKey or ed25519.PrivateKey.)
Unwrap() interface{}
// MarshalPEM returns the private key in PEM format.
MarshalPEM() ([]byte, error)
// Public returns the public key associated with the private key.
Public() PublicKey
}
func NewPrivateKey(k any) (PrivateKey, error) {
switch k := k.(type) {
case *ecdsa.PrivateKey:
return P256PrivateKey{k}, nil
case ed25519.PrivateKey:
return Ed25519PrivateKey{k}, nil
default:
return nil, fmt.Errorf("unsupported private key type: %T", k)
}
}

183
keys/pem.go Normal file
View file

@ -0,0 +1,183 @@
package keys
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/x509"
"encoding/pem"
"fmt"
)
const HostEd25519PublicKeyBanner = "CMESH HOST ED25519 PUBLIC KEY"
const HostEd25519PrivateKeyBanner = "CMESH HOST ED25519 PRIVATE KEY"
const HostP256PublicKeyBanner = "CMESH HOST P256 PUBLIC KEY"
const HostP256PrivateKeyBanner = "CMESH HOST P256 PRIVATE KEY"
const CmeshECDSAP256PublicKeyBanner = "CMESH ECDSA P256 PUBLIC KEY"
const CmeshEd25519PublicKeyBanner = "CMESH ED25519 PUBLIC KEY"
func MarshalHostEd25519PublicKey(k ed25519.PublicKey) ([]byte, error) {
b, err := x509.MarshalPKIXPublicKey(k)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{
Type: HostEd25519PublicKeyBanner,
Bytes: b,
}), nil
}
func MarshalHostEd25519PrivateKey(k ed25519.PrivateKey) ([]byte, error) {
b, err := x509.MarshalPKCS8PrivateKey(k)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{
Type: HostEd25519PrivateKeyBanner,
Bytes: b,
}), nil
}
func MarshalHostP256PublicKey(k *ecdsa.PublicKey) ([]byte, error) {
b, err := x509.MarshalPKIXPublicKey(k)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{
Type: HostP256PublicKeyBanner,
Bytes: b,
}), nil
}
func MarshalHostP256PrivateKey(k *ecdsa.PrivateKey) ([]byte, error) {
b, err := x509.MarshalECPrivateKey(k)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{
Type: HostP256PrivateKeyBanner,
Bytes: b,
}), nil
}
func UnmarshalHostEd25519PublicKey(b []byte) (ed25519.PublicKey, []byte, error) {
k, r := pem.Decode(b)
if k == nil {
return nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
}
if k.Type != HostEd25519PublicKeyBanner {
return nil, r, fmt.Errorf("bytes did not contain a proper DN Ed25519 public key banner")
}
pkey, err := x509.ParsePKIXPublicKey(k.Bytes)
if err != nil {
return nil, r, fmt.Errorf("failed to parse public key: %s", err)
}
return pkey.(ed25519.PublicKey), r, nil
}
func UnmarshalHostEd25519PrivateKey(b []byte) (ed25519.PrivateKey, []byte, error) {
k, r := pem.Decode(b)
if k == nil {
return nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
}
if k.Type != HostEd25519PrivateKeyBanner {
return nil, r, fmt.Errorf("bytes did not contain a proper DN Ed25519 private key banner")
}
pkey, err := x509.ParsePKCS8PrivateKey(k.Bytes)
if err != nil {
return nil, r, fmt.Errorf("failed to parse private key: %s", err)
}
return pkey.(ed25519.PrivateKey), r, nil
}
func UnmarshalHostP256PublicKey(b []byte) (*ecdsa.PublicKey, []byte, error) {
k, r := pem.Decode(b)
if k == nil {
return nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
}
if k.Type != HostP256PublicKeyBanner {
return nil, r, fmt.Errorf("bytes did not contain a proper DN P256 public key banner")
}
pkey, err := x509.ParsePKIXPublicKey(k.Bytes)
if err != nil {
return nil, r, fmt.Errorf("failed to parse public key: %s", err)
}
return pkey.(*ecdsa.PublicKey), r, nil
}
func UnmarshalHostP256PrivateKey(b []byte) (*ecdsa.PrivateKey, []byte, error) {
k, r := pem.Decode(b)
if k == nil {
return nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
}
if k.Type != HostP256PrivateKeyBanner {
return nil, r, fmt.Errorf("bytes did not contain a proper DN P256 private key banner")
}
pkey, err := x509.ParseECPrivateKey(k.Bytes)
if err != nil {
return nil, r, fmt.Errorf("failed to parse private key: %s", err)
}
return pkey, r, nil
}
func UnmarshalHostPrivateKey(b []byte) (PrivateKey, []byte, error) {
k, r := pem.Decode(b)
if k == nil {
return nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
}
switch k.Type {
case HostP256PrivateKeyBanner:
pkey, r, err := UnmarshalHostP256PrivateKey(b)
if err != nil {
return nil, r, err
}
pk, err := NewPrivateKey(pkey)
return pk, r, err
case HostEd25519PrivateKeyBanner:
pkey, r, err := UnmarshalHostEd25519PrivateKey(b)
if err != nil {
return nil, r, err
}
pk, err := NewPrivateKey(pkey)
return pk, r, err
default:
return nil, r, fmt.Errorf("input did not contain a valid private key banner")
}
}
func UnmarshalTrustedKey(b []byte) (TrustedKey, []byte, error) {
k, r := pem.Decode(b)
if k == nil {
return nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
}
switch k.Type {
case CmeshECDSAP256PublicKeyBanner:
if len(k.Bytes) != 65 {
return nil, r, fmt.Errorf("key was not 65 bytes, is invalid P256 public key")
}
x, y := elliptic.Unmarshal(elliptic.P256(), k.Bytes)
return P256TrustedKey{&ecdsa.PublicKey{X: x, Y: y, Curve: elliptic.P256()}}, r, nil
case CmeshEd25519PublicKeyBanner:
if len(k.Bytes) != ed25519.PublicKeySize {
return nil, r, fmt.Errorf("key was not 32 bytes, is invalid ed25519 public key")
}
return Ed25519TrustedKey{ed25519.PublicKey(k.Bytes)}, r, nil
default:
return nil, r, fmt.Errorf("input did not contain a valid public key banner")
}
}

94
keys/trusted_keys.go Normal file
View file

@ -0,0 +1,94 @@
package keys
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/sha256"
"encoding/pem"
"fmt"
)
// TrustedKey is an interface used to generically verify signatures returned
// from the DN API regardless of whether the key is P256 or 25519.
type TrustedKey interface {
Verify(data []byte, sig []byte) bool
Unwrap() any
MarshalPEM() ([]byte, error)
}
func NewTrustedKey(k any) (TrustedKey, error) {
switch k := k.(type) {
case *ecdsa.PublicKey:
return P256TrustedKey{k}, nil
case ed25519.PublicKey:
return Ed25519TrustedKey{k}, nil
default:
return nil, fmt.Errorf("unsupported private key type: %T", k)
}
}
// Ed25519TrustedKey is the Ed25519 implementation of TrustedKey.
type Ed25519TrustedKey struct {
ed25519.PublicKey
}
func (key Ed25519TrustedKey) Verify(data []byte, sig []byte) bool {
return ed25519.Verify(key.PublicKey, data, sig)
}
func (key Ed25519TrustedKey) Unwrap() any {
return key.PublicKey
}
func (key Ed25519TrustedKey) MarshalPEM() ([]byte, error) {
return pem.EncodeToMemory(&pem.Block{Type: CmeshEd25519PublicKeyBanner, Bytes: key.PublicKey}), nil
}
// P256TrustedKey is the P256 implementation of TrustedKey.
type P256TrustedKey struct {
*ecdsa.PublicKey
}
func (key P256TrustedKey) Verify(data []byte, sig []byte) bool {
hash := sha256.Sum256(data)
return ecdsa.VerifyASN1(key.PublicKey, hash[:], sig)
}
func (key P256TrustedKey) Unwrap() any {
return key.PublicKey
}
func (key P256TrustedKey) MarshalPEM() ([]byte, error) {
b := elliptic.Marshal(elliptic.P256(), key.X, key.Y)
return pem.EncodeToMemory(&pem.Block{Type: CmeshECDSAP256PublicKeyBanner, Bytes: b}), nil
}
// TrustedKeysToPEM converts a slice of TrustedKey to a PEM-encoded byte slice.
func TrustedKeysToPEM(keys []TrustedKey) ([]byte, error) {
result := []byte{}
for _, key := range keys {
pem, err := key.MarshalPEM()
if err != nil {
return nil, err
}
result = append(result, pem...)
}
return result, nil
}
// TrustedKeysFromPEM converts a PEM-encoded byte slice to a slice of TrustedKey.
func TrustedKeysFromPEM(pemKeys []byte) ([]TrustedKey, error) {
keys := []TrustedKey{}
for len(pemKeys) > 0 {
var err error
var pubkey TrustedKey
pubkey, pemKeys, err = UnmarshalTrustedKey(pemKeys)
if err != nil {
return nil, err
}
keys = append(keys, pubkey)
}
return keys, nil
}

207
message/message.go Normal file
View file

@ -0,0 +1,207 @@
package message
import (
"encoding/json"
"errors"
"strings"
"time"
)
// DNClient API message types
const (
CheckForUpdate = "CheckForUpdate"
DoUpdate = "DoUpdate"
LongPollWait = "LongPollWait"
CommandResponse = "CommandResponse"
)
// EndpointV1 is the version 1 DNClient API endpoint.
const EndpointV1 = "/v1/dnclient"
// RequestV1 is the version 1 DNClient request message.
// Ver is always 1, HostID is the calling dnclient hostID.
// Msg is a base64-encoded message, and Signature is an ed25519
// signature over the message, which can be verified using the
// host's previously enrolled ed25519 public key.
type RequestV1 struct {
Version int `json:"version"`
HostID string `json:"hostID"`
Counter uint `json:"counter"`
Message string `json:"message"`
Signature []byte `json:"signature"`
}
// RequestWrapper wraps a DNClient request message. It consists of a
// type and value, with the type string indicating how to interpret the value blob.
type RequestWrapper struct {
Type string `json:"type"`
Value []byte `json:"value"`
Timestamp time.Time `json:"timestamp"`
}
// SignedResponseWrapper contains a response message and a signature to validate inside "data."
type SignedResponseWrapper struct {
Data SignedResponse `json:"data"`
}
// SignedResponse contains a response message and a signature to validate.
type SignedResponse struct {
Version int `json:"version"`
Message []byte `json:"message"`
Signature []byte `json:"signature"`
}
// CheckForUpdateResponseWrapper contains a response to CheckForUpdate inside "data."
type CheckForUpdateResponseWrapper struct {
Data CheckForUpdateResponse `json:"data"`
}
// CheckForUpdateResponse is the response generated for a CheckForUpdate request.
type CheckForUpdateResponse struct {
UpdateAvailable bool `json:"updateAvailable"`
}
// DoUpdateRequest is the request sent for a DoUpdate request.
type DoUpdateRequest struct {
HostPubkeyEd25519 []byte `json:"edPubkeyPEM"` // X25519 (used for key exchange)
CmeshPubkeyX25519 []byte `json:"dhPubkeyPEM"` // Ed25519 (used for signing)
HostPubkeyP256 []byte `json:"p256HostPubkeyPEM"` // P256 (used for signing)
CmeshPubkeyP256 []byte `json:"p256CmeshPubkeyPEM"` // P256 (used for key exchange)
Nonce []byte `json:"nonce"`
}
// DoUpdateResponse is the response generated for a DoUpdate request.
type DoUpdateResponse struct {
Config []byte `json:"config"`
Counter uint `json:"counter"`
Nonce []byte `json:"nonce"`
TrustedKeys []byte `json:"trustedKeys"`
}
// LongPollWaitResponseWrapper contains a response to LongPollWait inside "data."
type LongPollWaitResponseWrapper struct {
Data LongPollWaitResponse `json:"data"`
}
// LongPollWaitRequest is the request message associated with a LongPollWait call.
type LongPollWaitRequest struct {
SupportedActions []string `json:"supportedActions"`
}
// LongPollWaitResponse is the response message associated with a LongPollWait call.
type LongPollWaitResponse struct {
Action json.RawMessage `json:"action"` // e.g. NoOp, StreamLogs, DoUpdate
ResponseToken string `json:"responseToken"`
}
// CommandResponseResponseWrapper contains a response to CommandResponse inside "data."
type CommandResponseResponseWrapper struct {
Data CommandResponseResponse `json:"data"`
}
// CommandResponseRequest is the request message associated with a CommandResponse call.
type CommandResponseRequest struct {
ResponseToken string `json:"responseToken"`
Response any `json:"response"`
}
// DNClientCommandResponseResponse is the response message associated with a CommandResponse call.
type CommandResponseResponse struct{}
type ClientInfo struct {
Identifier string `json:"identifier"`
Version string `json:"version"`
OS string `json:"os"`
Architecture string `json:"architecture"`
}
// EnrollEndpoint is the REST enrollment endpoint.
const EnrollEndpoint = "/v2/enroll"
// EnrollRequest is issued to the EnrollEndpoint.
type EnrollRequest struct {
Code string `json:"code"`
CmeshPubkeyX25519 []byte `json:"dhPubkey"` // X25519 (used for key exchange)
HostPubkeyEd25519 []byte `json:"edPubkey"` // Ed25519 (used for signing)
CmeshPubkeyP256 []byte `json:"cmeshPubkeyP256"` // P256 (used for key exchange)
HostPubkeyP256 []byte `json:"hostPubkeyP256"` // P256 (used for signing)
Timestamp time.Time `json:"timestamp"`
}
// EnrollResponse represents a response from the enrollment endpoint.
type EnrollResponse struct {
// Only one of Data or Errors should be set in a response
Data EnrollResponseData `json:"data"`
Errors APIErrors `json:"errors"`
}
// EnrollResponseData is included in the EnrollResponse.
type EnrollResponseData struct {
Config []byte `json:"config"`
HostID string `json:"hostID"`
Counter uint `json:"counter"`
TrustedKeys []byte `json:"trustedKeys"`
Organization EnrollResponseDataOrg `json:"organization"`
Network EnrollResponseDataNetwork `json:"network"`
}
// EnrollResponseDataOrg is included in EnrollResponseData.
type EnrollResponseDataOrg struct {
ID string `json:"id"`
Name string `json:"name"`
}
// EnrollResponseDataNetwork is included in EnrollResponseData.
type EnrollResponseDataNetwork struct {
ID string `json:"id"`
Curve NetworkCurve `json:"curve"`
}
// APIError represents a single error returned in an API error response.
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Path string `json:"path"` // may or may not be present
}
type APIErrors []APIError
func (errs APIErrors) ToError() error {
if len(errs) == 0 {
return nil
}
s := make([]string, len(errs))
for i := range errs {
s[i] = errs[i].Message
}
return errors.New(strings.Join(s, ", "))
}
// NetworkCurve represents the network curve specified by the API.
type NetworkCurve string
const (
NetworkCurve25519 NetworkCurve = "25519"
NetworkCurveP256 NetworkCurve = "P256"
)
func (nc *NetworkCurve) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
switch s {
case "25519":
*nc = NetworkCurve25519
case "P256":
*nc = NetworkCurveP256
default:
return errors.New("invalid network curve")
}
return nil
}

27
scripts/build/build.sh Executable file
View file

@ -0,0 +1,27 @@
#!/bin/bash
################################################################################
# Intended use: Compile and extract software packages
#
# Notes: This script support Linux Bash shell script only
# Install Bash from https://www.gnu.org/software/bash/
#
# Copyright (C) 2015 - 2025, CT129 Dev Team <dev@ct129.com>
################################################################################
# Source path
export SCRIPT=$(readlink -f "$0")
export SCRIPT_PATH=$(dirname "$SCRIPT")
export SRC_DIR=${SCRIPT_PATH}/../..
export APP_NAME=$(basename $(dirname $(dirname "${SCRIPT_PATH}")))
cd ${SRC_DIR}
# Make for all *nix platfomr
make all
# Make for MS Windows
make bin-windows
################################################################################
# BASH SCRIPT ON LINUX/UNIX - END
################################################################################

25
scripts/util/util_git_push.sh Executable file
View file

@ -0,0 +1,25 @@
#!/bin/bash
################################################################################
# Intended use: Compile and extract software packages
#
# Notes: This script support Linux Bash shell script only
# Install Bash from https://www.gnu.org/software/bash/
#
# Copyright (C) 2015 - 2025, CT129 Dev Team <dev@ct129.com>
################################################################################
export GIT_BRACNH="main"
export TIMESTAMP=`date +"%Y-%m-%d %H:%M:%S"`
export SCRIPT=$(readlink -f "$0")
export SCRIPT_PATH=$(dirname "$SCRIPT")
export SRC_DIR=${SCRIPT_PATH}/../..
cd ${SRC_DIR}
git add .
git checkout ${GIT_BRACNH}
git commit -m "${TIMESTAMP}"
git push -u origin ${GIT_BRACNH}
################################################################################
# BASH SCRIPT ON LINUX/UNIX - END
################################################################################

View file

@ -0,0 +1,29 @@
#!/bin/bash
################################################################################
# Intended use: Compile and extract software packages
#
# Notes: This script support Linux Bash shell script only
# Install Bash from https://www.gnu.org/software/bash/
#
# Copyright (C) 2015 - 2025, CT129 Dev Team <dev@ct129.com>
################################################################################
export GIT_BRACNH="main"
export GIT_TAG="v1.9.5"
export TIMESTAMP=`date +"%Y-%m-%d %H:%M:%S"`
export SCRIPT=$(readlink -f "$0")
export SCRIPT_PATH=$(dirname "$SCRIPT")
export SRC_DIR=${SCRIPT_PATH}/../..
cd ${SRC_DIR}
git add .
git checkout ${GIT_BRACNH}
git commit -m "${TIMESTAMP}"
git tag -d ${GIT_TAG}
git push origin :refs/tags/${GIT_TAG}
git tag -a ${GIT_TAG} -m ${GIT_TAG}
git push --tags
################################################################################
# BASH SCRIPT ON LINUX/UNIX - END
################################################################################

41
sign.go Normal file
View file

@ -0,0 +1,41 @@
package cmapi
import (
"encoding/base64"
"encoding/json"
"time"
"git.ct129.com/VPN/cmapi/keys"
"git.ct129.com/VPN/cmapi/message"
)
func SignRequestV1(reqType string, value []byte, hostID string, counter uint, privkey keys.PrivateKey) ([]byte, error) {
encMsg, err := json.Marshal(message.RequestWrapper{
Type: reqType,
Value: value,
Timestamp: time.Now(),
})
if err != nil {
return nil, err
}
signedMsg := base64.StdEncoding.EncodeToString(encMsg)
sig, err := privkey.Sign([]byte(signedMsg))
if err != nil {
return nil, err
}
wrapper := message.RequestV1{
Version: 1,
HostID: hostID,
Counter: counter,
Message: signedMsg,
Signature: sig,
}
b, err := json.Marshal(wrapper)
if err != nil {
return nil, err
}
return b, nil
}