Skip to content

Commit 2105a74

Browse files
marwan-at-workTyler Smalley
and
Tyler Smalley
authored
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 <[email protected]> Co-authored-by: Tyler Smalley <[email protected]>
1 parent 7a627b1 commit 2105a74

File tree

4 files changed

+166
-9
lines changed

4 files changed

+166
-9
lines changed

profiles/offline.json

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"MockOffline": true
3+
}

profiles/snapshot.mjs

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { exec } from 'child_process';
2+
import fs from 'fs';
3+
4+
function executeCommand(command) {
5+
const fullCommand = `tailscale ${command} --json`;
6+
return new Promise((resolve, reject) => {
7+
exec(fullCommand, (error, stdout, stderr) => {
8+
if (error) {
9+
// If command not found in PATH, try with zsh
10+
if (error.code === 127) {
11+
exec(`zsh -i -c "${fullCommand}"`, (zshError, zshStdout, zshStderr) => {
12+
if (zshError) {
13+
reject(zshError);
14+
} else {
15+
resolve(JSON.parse(zshStdout.trim()));
16+
}
17+
});
18+
} else {
19+
reject(error);
20+
}
21+
} else {
22+
try {
23+
const parsedOutput = JSON.parse(stdout.trim());
24+
resolve(parsedOutput);
25+
} catch (parseError) {
26+
resolve({});
27+
}
28+
}
29+
});
30+
});
31+
}
32+
33+
function exportResults(profileName, results) {
34+
const filename = `${profileName}.json`;
35+
fs.writeFileSync(filename, JSON.stringify(results, null, 2));
36+
console.log(`Results exported to ${filename}`);
37+
}
38+
39+
async function runCommands(profileName) {
40+
try {
41+
const results = {};
42+
43+
results.Status = await executeCommand('status');
44+
results.ServeConfig = await executeCommand('serve status');
45+
46+
// Export results to JSON file
47+
exportResults(profileName, results);
48+
} catch (error) {
49+
console.error('Error:', error.message);
50+
throw error;
51+
}
52+
}
53+
54+
const profileName = process.argv[2];
55+
56+
if (profileName) {
57+
runCommands(profileName);
58+
} else {
59+
console.error('Please provide a profile name as an argument.');
60+
}

tsrelay/local_client.go

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net"
7+
"os"
8+
"sync"
9+
10+
"tailscale.com/client/tailscale"
11+
"tailscale.com/ipn"
12+
"tailscale.com/ipn/ipnstate"
13+
)
14+
15+
// static check for local client interface implementation
16+
var _ localClient = (*tailscale.LocalClient)(nil)
17+
18+
// localClient is an abstraction of tailscale.LocalClient
19+
type localClient interface {
20+
Status(ctx context.Context) (*ipnstate.Status, error)
21+
GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error)
22+
StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error)
23+
SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error
24+
}
25+
26+
type profile struct {
27+
Status *ipnstate.Status
28+
ServeConfig *ipn.ServeConfig
29+
MockOffline bool
30+
MockAccessDenied bool
31+
}
32+
33+
// NewMockClient returns a mock localClient
34+
// based on the given json file. The format of the file
35+
// is described in the profile struct. Note that SET
36+
// operations update the given input in memory.
37+
func NewMockClient(file string) (localClient, error) {
38+
bts, err := os.ReadFile(file)
39+
if err != nil {
40+
return nil, err
41+
}
42+
var p profile
43+
return &mockClient{p: &p}, json.Unmarshal(bts, &p)
44+
}
45+
46+
type mockClient struct {
47+
sync.Mutex
48+
p *profile
49+
}
50+
51+
// GetServeConfig implements localClient.
52+
func (m *mockClient) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) {
53+
if m.p.MockOffline {
54+
return nil, &net.OpError{Op: "dial"}
55+
}
56+
return m.p.ServeConfig, nil
57+
}
58+
59+
// SetServeConfig implements localClient.
60+
func (m *mockClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
61+
if m.p.MockAccessDenied {
62+
return &tailscale.AccessDeniedError{}
63+
}
64+
m.Lock()
65+
defer m.Unlock()
66+
m.p.ServeConfig = config
67+
return nil
68+
}
69+
70+
// Status implements localClient.
71+
func (m *mockClient) Status(ctx context.Context) (*ipnstate.Status, error) {
72+
if m.p.MockOffline || m.p.Status == nil {
73+
return nil, &net.OpError{Op: "dial"}
74+
}
75+
return m.p.Status, nil
76+
}
77+
78+
// StatusWithoutPeers implements localClient.
79+
func (m *mockClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
80+
if m.p.MockOffline || m.p.Status == nil {
81+
return nil, &net.OpError{Op: "dial"}
82+
}
83+
copy := *(m.p.Status)
84+
copy.Peer = nil
85+
return &copy, nil
86+
}

tsrelay/main.go

+17-9
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ import (
3131
)
3232

3333
var (
34-
logfile = flag.String("logfile", "", "send logs to a file instead of stderr")
35-
verbose = flag.Bool("v", false, "verbose logging")
36-
port = flag.Int("port", 0, "port for http server. If 0, one will be chosen")
37-
nonce = flag.String("nonce", "", "nonce for the http server")
38-
socket = flag.String("socket", "", "alternative path for local api socket")
34+
logfile = flag.String("logfile", "", "send logs to a file instead of stderr")
35+
verbose = flag.Bool("v", false, "verbose logging")
36+
port = flag.Int("port", 0, "port for http server. If 0, one will be chosen")
37+
nonce = flag.String("nonce", "", "nonce for the http server")
38+
socket = flag.String("socket", "", "alternative path for local api socket")
39+
mockFile = flag.String("mockfile", "", "a profile file to mock LocalClient responses")
3940
)
4041

4142
// ErrorTypes for signaling
@@ -152,11 +153,18 @@ func runHTTPServer(ctx context.Context, lggr *logger, port int, nonce string) er
152153
Nonce: nonce,
153154
}
154155
json.NewEncoder(os.Stdout).Encode(sd)
156+
var lc localClient = &tailscale.LocalClient{
157+
Socket: *socket,
158+
}
159+
if *mockFile != "" {
160+
lc, err = NewMockClient(*mockFile)
161+
if err != nil {
162+
return fmt.Errorf("error creating mock client: %w", err)
163+
}
164+
}
155165
s := &http.Server{
156166
Handler: &httpHandler{
157-
lc: tailscale.LocalClient{
158-
Socket: *socket,
159-
},
167+
lc: lc,
160168
nonce: nonce,
161169
l: lggr,
162170
pids: make(map[int]struct{}),
@@ -179,7 +187,7 @@ func getNonce() string {
179187
type httpHandler struct {
180188
sync.Mutex
181189
nonce string
182-
lc tailscale.LocalClient
190+
lc localClient
183191
l *logger
184192
u websocket.Upgrader
185193
pids map[int]struct{}

0 commit comments

Comments
 (0)