From 2105a74e63ed1e34b210888c5d650adfe68bcefe Mon Sep 17 00:00:00 2001 From: Marwan Sulaiman Date: Thu, 29 Jun 2023 16:23:05 -0400 Subject: [PATCH] Add ability to mock LocalClient (#90) This PR adds the ability to mock LocalClient responses via `-mockfile` so that we can test vscode <-> tsrelay communication without requiring a live tailscaled server. --------- Signed-off-by: Tyler Smalley Co-authored-by: Tyler Smalley --- profiles/offline.json | 3 ++ profiles/snapshot.mjs | 60 ++++++++++++++++++++++++++++ tsrelay/local_client.go | 86 +++++++++++++++++++++++++++++++++++++++++ tsrelay/main.go | 26 ++++++++----- 4 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 profiles/offline.json create mode 100644 profiles/snapshot.mjs create mode 100644 tsrelay/local_client.go diff --git a/profiles/offline.json b/profiles/offline.json new file mode 100644 index 0000000..168c22b --- /dev/null +++ b/profiles/offline.json @@ -0,0 +1,3 @@ +{ + "MockOffline": true +} diff --git a/profiles/snapshot.mjs b/profiles/snapshot.mjs new file mode 100644 index 0000000..f5c208f --- /dev/null +++ b/profiles/snapshot.mjs @@ -0,0 +1,60 @@ +import { exec } from 'child_process'; +import fs from 'fs'; + +function executeCommand(command) { + const fullCommand = `tailscale ${command} --json`; + return new Promise((resolve, reject) => { + exec(fullCommand, (error, stdout, stderr) => { + if (error) { + // If command not found in PATH, try with zsh + if (error.code === 127) { + exec(`zsh -i -c "${fullCommand}"`, (zshError, zshStdout, zshStderr) => { + if (zshError) { + reject(zshError); + } else { + resolve(JSON.parse(zshStdout.trim())); + } + }); + } else { + reject(error); + } + } else { + try { + const parsedOutput = JSON.parse(stdout.trim()); + resolve(parsedOutput); + } catch (parseError) { + resolve({}); + } + } + }); + }); +} + +function exportResults(profileName, results) { + const filename = `${profileName}.json`; + fs.writeFileSync(filename, JSON.stringify(results, null, 2)); + console.log(`Results exported to ${filename}`); +} + +async function runCommands(profileName) { + try { + const results = {}; + + results.Status = await executeCommand('status'); + results.ServeConfig = await executeCommand('serve status'); + + // Export results to JSON file + exportResults(profileName, results); + } catch (error) { + console.error('Error:', error.message); + throw error; + } +} + +const profileName = process.argv[2]; + +if (profileName) { + runCommands(profileName); +} else { + console.error('Please provide a profile name as an argument.'); +} diff --git a/tsrelay/local_client.go b/tsrelay/local_client.go new file mode 100644 index 0000000..fcc6cf4 --- /dev/null +++ b/tsrelay/local_client.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "encoding/json" + "net" + "os" + "sync" + + "tailscale.com/client/tailscale" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" +) + +// static check for local client interface implementation +var _ localClient = (*tailscale.LocalClient)(nil) + +// localClient is an abstraction of tailscale.LocalClient +type localClient interface { + Status(ctx context.Context) (*ipnstate.Status, error) + GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) + StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) + SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error +} + +type profile struct { + Status *ipnstate.Status + ServeConfig *ipn.ServeConfig + MockOffline bool + MockAccessDenied bool +} + +// NewMockClient returns a mock localClient +// based on the given json file. The format of the file +// is described in the profile struct. Note that SET +// operations update the given input in memory. +func NewMockClient(file string) (localClient, error) { + bts, err := os.ReadFile(file) + if err != nil { + return nil, err + } + var p profile + return &mockClient{p: &p}, json.Unmarshal(bts, &p) +} + +type mockClient struct { + sync.Mutex + p *profile +} + +// GetServeConfig implements localClient. +func (m *mockClient) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) { + if m.p.MockOffline { + return nil, &net.OpError{Op: "dial"} + } + return m.p.ServeConfig, nil +} + +// SetServeConfig implements localClient. +func (m *mockClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error { + if m.p.MockAccessDenied { + return &tailscale.AccessDeniedError{} + } + m.Lock() + defer m.Unlock() + m.p.ServeConfig = config + return nil +} + +// Status implements localClient. +func (m *mockClient) Status(ctx context.Context) (*ipnstate.Status, error) { + if m.p.MockOffline || m.p.Status == nil { + return nil, &net.OpError{Op: "dial"} + } + return m.p.Status, nil +} + +// StatusWithoutPeers implements localClient. +func (m *mockClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) { + if m.p.MockOffline || m.p.Status == nil { + return nil, &net.OpError{Op: "dial"} + } + copy := *(m.p.Status) + copy.Peer = nil + return ©, nil +} diff --git a/tsrelay/main.go b/tsrelay/main.go index b886b9c..48f0eb8 100644 --- a/tsrelay/main.go +++ b/tsrelay/main.go @@ -31,11 +31,12 @@ import ( ) var ( - logfile = flag.String("logfile", "", "send logs to a file instead of stderr") - verbose = flag.Bool("v", false, "verbose logging") - port = flag.Int("port", 0, "port for http server. If 0, one will be chosen") - nonce = flag.String("nonce", "", "nonce for the http server") - socket = flag.String("socket", "", "alternative path for local api socket") + logfile = flag.String("logfile", "", "send logs to a file instead of stderr") + verbose = flag.Bool("v", false, "verbose logging") + port = flag.Int("port", 0, "port for http server. If 0, one will be chosen") + nonce = flag.String("nonce", "", "nonce for the http server") + socket = flag.String("socket", "", "alternative path for local api socket") + mockFile = flag.String("mockfile", "", "a profile file to mock LocalClient responses") ) // ErrorTypes for signaling @@ -152,11 +153,18 @@ func runHTTPServer(ctx context.Context, lggr *logger, port int, nonce string) er Nonce: nonce, } json.NewEncoder(os.Stdout).Encode(sd) + var lc localClient = &tailscale.LocalClient{ + Socket: *socket, + } + if *mockFile != "" { + lc, err = NewMockClient(*mockFile) + if err != nil { + return fmt.Errorf("error creating mock client: %w", err) + } + } s := &http.Server{ Handler: &httpHandler{ - lc: tailscale.LocalClient{ - Socket: *socket, - }, + lc: lc, nonce: nonce, l: lggr, pids: make(map[int]struct{}), @@ -179,7 +187,7 @@ func getNonce() string { type httpHandler struct { sync.Mutex nonce string - lc tailscale.LocalClient + lc localClient l *logger u websocket.Upgrader pids map[int]struct{}