Skip to content

Commit fec962e

Browse files
committed
feat(capabilities): add power-user diagnostics
1 parent e84a0ef commit fec962e

12 files changed

Lines changed: 838 additions & 4 deletions

File tree

internal/capabilities/net.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func RegisterNet(r *dispatch.Router) {
2222
r.Register("net.firewall", netFirewall)
2323
r.Register("net.wlan", netWlan)
2424
r.Register("net.tls", netTLS)
25+
r.Register("net.shares", netShares)
2526
}
2627

2728
func netAdapters(ctx context.Context, _ map[string]json.RawMessage) (interface{}, error) {

internal/capabilities/net_probes.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"fmt"
88
"net"
99
"strconv"
10+
"strings"
11+
"time"
1012

1113
"github.com/RealWhyKnot/Handoff/internal/dispatch"
1214
)
@@ -17,6 +19,7 @@ import (
1719
func RegisterNetProbes(r *dispatch.Router) {
1820
r.Register("net.ping", netPing)
1921
r.Register("net.trace", netTrace)
22+
r.Register("net.tcp-test", netTCPTest)
2023
}
2124

2225
func validTarget(s string) bool {
@@ -72,3 +75,85 @@ func netTrace(ctx context.Context, args map[string]json.RawMessage) (interface{}
7275
ConvertTo-Json -Compress -Depth 4`
7376
return runPwshJSON(ctx, script)
7477
}
78+
79+
type tcpTestOptions struct {
80+
target string
81+
port int
82+
timeoutMS int
83+
}
84+
85+
func parseTCPTestOptions(args map[string]json.RawMessage) (tcpTestOptions, error) {
86+
opts := tcpTestOptions{timeoutMS: 5000}
87+
if v, ok := args["target"]; ok {
88+
_ = json.Unmarshal(v, &opts.target)
89+
}
90+
if v, ok := args["port"]; ok {
91+
_ = json.Unmarshal(v, &opts.port)
92+
}
93+
if v, ok := args["timeout_ms"]; ok {
94+
_ = json.Unmarshal(v, &opts.timeoutMS)
95+
}
96+
opts.target = strings.TrimSpace(opts.target)
97+
if !validTarget(opts.target) {
98+
return opts, fmt.Errorf("net.tcp-test: invalid 'target'")
99+
}
100+
if opts.port <= 0 || opts.port > 65535 {
101+
return opts, fmt.Errorf("net.tcp-test: port must be 1-65535")
102+
}
103+
if opts.timeoutMS < 1000 {
104+
opts.timeoutMS = 1000
105+
}
106+
if opts.timeoutMS > 30000 {
107+
opts.timeoutMS = 30000
108+
}
109+
return opts, nil
110+
}
111+
112+
func netTCPTest(ctx context.Context, args map[string]json.RawMessage) (interface{}, error) {
113+
opts, err := parseTCPTestOptions(args)
114+
if err != nil {
115+
return nil, err
116+
}
117+
118+
start := time.Now()
119+
timeout := time.Duration(opts.timeoutMS) * time.Millisecond
120+
result := map[string]interface{}{
121+
"target": opts.target,
122+
"port": opts.port,
123+
"timeout_ms": opts.timeoutMS,
124+
"tcp_test_succeeded": false,
125+
}
126+
127+
lookupCtx, lookupCancel := context.WithTimeout(ctx, timeout)
128+
addrs, lookupErr := net.DefaultResolver.LookupIPAddr(lookupCtx, opts.target)
129+
lookupCancel()
130+
resolved := make([]string, 0, len(addrs))
131+
for _, addr := range addrs {
132+
resolved = append(resolved, addr.IP.String())
133+
}
134+
result["resolved_addresses"] = resolved
135+
if lookupErr != nil {
136+
result["error"] = lookupErr.Error()
137+
result["elapsed_ms"] = time.Since(start).Milliseconds()
138+
return result, nil
139+
}
140+
if len(resolved) > 0 {
141+
result["remote_address"] = net.JoinHostPort(resolved[0], strconv.Itoa(opts.port))
142+
}
143+
144+
dialCtx, dialCancel := context.WithTimeout(ctx, timeout)
145+
conn, dialErr := (&net.Dialer{Timeout: timeout}).DialContext(dialCtx, "tcp", net.JoinHostPort(opts.target, strconv.Itoa(opts.port)))
146+
dialCancel()
147+
if dialErr != nil {
148+
result["error"] = dialErr.Error()
149+
result["elapsed_ms"] = time.Since(start).Milliseconds()
150+
return result, nil
151+
}
152+
defer conn.Close()
153+
154+
result["tcp_test_succeeded"] = true
155+
result["remote_address"] = conn.RemoteAddr().String()
156+
result["local_address"] = conn.LocalAddr().String()
157+
result["elapsed_ms"] = time.Since(start).Milliseconds()
158+
return result, nil
159+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
package capabilities
3+
4+
import (
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
)
9+
10+
type netSharesOptions struct {
11+
includeHidden bool
12+
includeSessions bool
13+
maxResults int
14+
}
15+
16+
func parseNetSharesOptions(args map[string]json.RawMessage) netSharesOptions {
17+
opts := netSharesOptions{
18+
includeSessions: true,
19+
maxResults: 200,
20+
}
21+
if v, ok := args["include_hidden"]; ok {
22+
_ = json.Unmarshal(v, &opts.includeHidden)
23+
}
24+
if v, ok := args["include_sessions"]; ok {
25+
_ = json.Unmarshal(v, &opts.includeSessions)
26+
}
27+
if v, ok := args["max_results"]; ok {
28+
_ = json.Unmarshal(v, &opts.maxResults)
29+
}
30+
if opts.maxResults <= 0 {
31+
opts.maxResults = 200
32+
}
33+
if opts.maxResults > 1000 {
34+
opts.maxResults = 1000
35+
}
36+
return opts
37+
}
38+
39+
func netShares(ctx context.Context, args map[string]json.RawMessage) (interface{}, error) {
40+
opts := parseNetSharesOptions(args)
41+
script := fmt.Sprintf(`
42+
$includeHidden = $%t
43+
$includeSessions = $%t
44+
$max = %d
45+
$errors = [ordered]@{}
46+
$shareEntries = @()
47+
$sessionEntries = @()
48+
$openFileEntries = @()
49+
50+
try {
51+
$shareArgs = @{ ErrorAction = 'Stop' }
52+
if ($includeHidden) { $shareArgs['IncludeHidden'] = $true }
53+
$shareEntries = Get-SmbShare @shareArgs |
54+
Sort-Object Name |
55+
Select-Object -First $max |
56+
ForEach-Object {
57+
[ordered]@{
58+
name = [string]$_.Name
59+
path = [string]$_.Path
60+
description = [string]$_.Description
61+
share_state = [string]$_.ShareState
62+
share_type = [string]$_.ShareType
63+
current_users = if ($null -ne $_.CurrentUsers) { [int]$_.CurrentUsers } else { $null }
64+
special = [bool]$_.Special
65+
encrypt_data = [bool]$_.EncryptData
66+
caching_mode = [string]$_.CachingMode
67+
folder_enumeration_mode = [string]$_.FolderEnumerationMode
68+
}
69+
}
70+
} catch {
71+
$errors['shares'] = $_.Exception.Message
72+
}
73+
74+
if ($includeSessions) {
75+
try {
76+
$sessionEntries = Get-SmbSession -ErrorAction Stop |
77+
Sort-Object ClientComputerName, ClientUserName |
78+
Select-Object -First $max |
79+
ForEach-Object {
80+
[ordered]@{
81+
session_id = [string]$_.SessionId
82+
client_computer_name = [string]$_.ClientComputerName
83+
client_user_name = [string]$_.ClientUserName
84+
num_opens = if ($null -ne $_.NumOpens) { [int]$_.NumOpens } else { $null }
85+
seconds_exists = if ($null -ne $_.SecondsExists) { [int64]$_.SecondsExists } else { $null }
86+
seconds_idle = if ($null -ne $_.SecondsIdle) { [int64]$_.SecondsIdle } else { $null }
87+
dialect = [string]$_.Dialect
88+
}
89+
}
90+
} catch {
91+
$errors['sessions'] = $_.Exception.Message
92+
}
93+
94+
try {
95+
$openFileEntries = Get-SmbOpenFile -ErrorAction Stop |
96+
Sort-Object ClientComputerName, ShareRelativePath |
97+
Select-Object -First $max |
98+
ForEach-Object {
99+
[ordered]@{
100+
file_id = [string]$_.FileId
101+
session_id = [string]$_.SessionId
102+
client_computer_name = [string]$_.ClientComputerName
103+
client_user_name = [string]$_.ClientUserName
104+
share_relative_path = [string]$_.ShareRelativePath
105+
permissions = [string]$_.Permissions
106+
locks = if ($null -ne $_.Locks) { [int]$_.Locks } else { $null }
107+
}
108+
}
109+
} catch {
110+
$errors['open_files'] = $_.Exception.Message
111+
}
112+
}
113+
114+
[ordered]@{
115+
include_hidden = $includeHidden
116+
include_sessions = $includeSessions
117+
max = $max
118+
share_count = @($shareEntries).Count
119+
session_count = @($sessionEntries).Count
120+
open_file_count = @($openFileEntries).Count
121+
shares = @($shareEntries)
122+
sessions = @($sessionEntries)
123+
open_files = @($openFileEntries)
124+
errors = $errors
125+
} | ConvertTo-Json -Compress -Depth 5
126+
`, opts.includeHidden, opts.includeSessions, opts.maxResults)
127+
return runPwshJSON(ctx, script)
128+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
package capabilities
3+
4+
import (
5+
"context"
6+
"encoding/json"
7+
"runtime"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/RealWhyKnot/Handoff/internal/dispatch"
13+
)
14+
15+
func TestParseTCPTestOptionsDefaultsAndClamps(t *testing.T) {
16+
opts, err := parseTCPTestOptions(rawArgs(t, map[string]interface{}{
17+
"target": " example.com ",
18+
"port": 443,
19+
"timeout_ms": 20,
20+
}))
21+
if err != nil {
22+
t.Fatalf("parseTCPTestOptions err = %v", err)
23+
}
24+
if opts.target != "example.com" {
25+
t.Fatalf("target = %q, want example.com", opts.target)
26+
}
27+
if opts.port != 443 {
28+
t.Fatalf("port = %d, want 443", opts.port)
29+
}
30+
if opts.timeoutMS != 1000 {
31+
t.Fatalf("timeoutMS = %d, want lower clamp", opts.timeoutMS)
32+
}
33+
34+
opts, err = parseTCPTestOptions(rawArgs(t, map[string]interface{}{
35+
"target": "example.com",
36+
"port": 443,
37+
"timeout_ms": 60000,
38+
}))
39+
if err != nil {
40+
t.Fatalf("parseTCPTestOptions high timeout err = %v", err)
41+
}
42+
if opts.timeoutMS != 30000 {
43+
t.Fatalf("timeoutMS = %d, want upper clamp", opts.timeoutMS)
44+
}
45+
}
46+
47+
func TestParseTCPTestOptionsRejectsBadInput(t *testing.T) {
48+
_, err := parseTCPTestOptions(rawArgs(t, map[string]interface{}{
49+
"target": "example.com; rm",
50+
"port": 443,
51+
}))
52+
if err == nil || !strings.Contains(err.Error(), "target") {
53+
t.Fatalf("target err = %v, want validation", err)
54+
}
55+
56+
_, err = parseTCPTestOptions(rawArgs(t, map[string]interface{}{
57+
"target": "example.com",
58+
"port": 70000,
59+
}))
60+
if err == nil || !strings.Contains(err.Error(), "port") {
61+
t.Fatalf("port err = %v, want validation", err)
62+
}
63+
}
64+
65+
func TestStorageDriveLetterArgNormalizesAndRejectsBadInput(t *testing.T) {
66+
got, err := storageDriveLetterArg(rawArgs(t, map[string]interface{}{
67+
"drive_letter": " c: ",
68+
}))
69+
if err != nil {
70+
t.Fatalf("storageDriveLetterArg err = %v", err)
71+
}
72+
if got != "C" {
73+
t.Fatalf("drive letter = %q, want C", got)
74+
}
75+
76+
_, err = storageDriveLetterArg(rawArgs(t, map[string]interface{}{
77+
"drive_letter": "System",
78+
}))
79+
if err == nil || !strings.Contains(err.Error(), "single letter") {
80+
t.Fatalf("storageDriveLetterArg err = %v, want validation", err)
81+
}
82+
}
83+
84+
func TestPowerUserBoundedArgs(t *testing.T) {
85+
if got := resourceTopArg(rawArgs(t, map[string]interface{}{"top": 500})); got != 50 {
86+
t.Fatalf("resourceTopArg = %d, want 50", got)
87+
}
88+
if got := resourceTopArg(rawArgs(t, map[string]interface{}{"top": 0})); got != 10 {
89+
t.Fatalf("resourceTopArg zero = %d, want default", got)
90+
}
91+
if got := startupMaxResultsArg(rawArgs(t, map[string]interface{}{"max_results": 5000})); got != 2000 {
92+
t.Fatalf("startupMaxResultsArg = %d, want 2000", got)
93+
}
94+
if got := startupMaxResultsArg(rawArgs(t, map[string]interface{}{"max_results": -1})); got != 300 {
95+
t.Fatalf("startupMaxResultsArg negative = %d, want default", got)
96+
}
97+
98+
shares := parseNetSharesOptions(rawArgs(t, map[string]interface{}{
99+
"include_hidden": true,
100+
"include_sessions": false,
101+
"max_results": 5000,
102+
}))
103+
if !shares.includeHidden || shares.includeSessions || shares.maxResults != 1000 {
104+
t.Fatalf("parseNetSharesOptions = %#v, want hidden=true sessions=false max=1000", shares)
105+
}
106+
}
107+
108+
func TestRegisterAllIncludesPowerUserKinds(t *testing.T) {
109+
r := dispatch.New()
110+
RegisterAll(r, nil)
111+
kinds := map[string]bool{}
112+
for _, kind := range r.Kinds() {
113+
kinds[kind] = true
114+
}
115+
for _, want := range []string{
116+
"net.tcp-test",
117+
"storage.volumes",
118+
"sys.resources",
119+
"startup.list",
120+
"net.shares",
121+
"sec.local-admins",
122+
} {
123+
if !kinds[want] {
124+
t.Fatalf("registered kinds missing %s in %#v", want, r.Kinds())
125+
}
126+
}
127+
}
128+
129+
func TestPowerUserPowerShellCapabilitiesSmoke(t *testing.T) {
130+
if runtime.GOOS != "windows" {
131+
t.Skip("PowerShell-backed smoke test requires Windows")
132+
}
133+
134+
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
135+
defer cancel()
136+
cases := []struct {
137+
name string
138+
fn func(context.Context, map[string]json.RawMessage) (interface{}, error)
139+
args map[string]json.RawMessage
140+
}{
141+
{"storage.volumes", storageVolumes, rawArgs(t, map[string]interface{}{"drive_letter": "C"})},
142+
{"sys.resources", sysResources, rawArgs(t, map[string]interface{}{"top": 3})},
143+
{"startup.list", startupList, rawArgs(t, map[string]interface{}{"max_results": 5})},
144+
{"sec.local-admins", secLocalAdmins, rawArgs(t, map[string]interface{}{})},
145+
{"net.shares", netShares, rawArgs(t, map[string]interface{}{"max_results": 5})},
146+
}
147+
for _, tc := range cases {
148+
res, err := tc.fn(ctx, tc.args)
149+
if err != nil {
150+
t.Fatalf("%s err = %v", tc.name, err)
151+
}
152+
if res == nil {
153+
t.Fatalf("%s returned nil result", tc.name)
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)