Sandboxed commands previously ran as `sudo -u _greywall`, breaking user identity (home dir, SSH keys, git config). Now uses `sudo -u #<uid> -g _greywall` so the process keeps the real user's identity while pf matches on EGID for traffic routing. Key changes: - pf rules use `group <GID>` instead of `user _greywall` - GID resolved dynamically at daemon startup (not hardcoded, since macOS system groups like com.apple.access_ssh may claim preferred IDs) - Sudoers rule installed at /etc/sudoers.d/greywall (validated with visudo) - Invoking user added to _greywall group via dscl (not dseditgroup, which clobbers group attributes) - tun2socks device discovery scans both stdout and stderr (fixes 10s timeout caused by STACK message going to stdout) - Always-on daemon logging for session create/destroy events
528 lines
14 KiB
Go
528 lines
14 KiB
Go
package daemon
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testSocketPath returns a temporary Unix socket path for testing.
|
|
// macOS limits Unix socket paths to 104 bytes, so we use a short temp directory
|
|
// under /tmp rather than the longer t.TempDir() paths.
|
|
func testSocketPath(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("/tmp", "gw-")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
sockPath := filepath.Join(dir, "d.sock")
|
|
t.Cleanup(func() {
|
|
_ = os.RemoveAll(dir)
|
|
})
|
|
return sockPath
|
|
}
|
|
|
|
func TestServerStartStop(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
|
|
// Verify socket file exists.
|
|
info, err := os.Stat(sockPath)
|
|
if err != nil {
|
|
t.Fatalf("Socket file not found: %v", err)
|
|
}
|
|
|
|
// Verify socket permissions (0666 — any local user can connect).
|
|
perm := info.Mode().Perm()
|
|
if perm != 0o666 {
|
|
t.Errorf("Expected socket permissions 0666, got %o", perm)
|
|
}
|
|
|
|
// Verify no active sessions at start.
|
|
if n := srv.ActiveSessions(); n != 0 {
|
|
t.Errorf("Expected 0 active sessions, got %d", n)
|
|
}
|
|
|
|
if err := srv.Stop(); err != nil {
|
|
t.Fatalf("Stop failed: %v", err)
|
|
}
|
|
|
|
// Verify socket file is removed after stop.
|
|
if _, err := os.Stat(sockPath); !os.IsNotExist(err) {
|
|
t.Error("Socket file should be removed after stop")
|
|
}
|
|
}
|
|
|
|
func TestServerStartRemovesStaleSocket(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
// Create a stale socket file.
|
|
if err := os.WriteFile(sockPath, []byte("stale"), 0o600); err != nil {
|
|
t.Fatalf("Failed to create stale file: %v", err)
|
|
}
|
|
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed with stale socket: %v", err)
|
|
}
|
|
defer srv.Stop() //nolint:errcheck // test cleanup
|
|
|
|
// Verify the server is listening by connecting.
|
|
conn, err := net.DialTimeout("unix", sockPath, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("Failed to connect to server: %v", err)
|
|
}
|
|
_ = conn.Close()
|
|
}
|
|
|
|
func TestServerDoubleStop(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", false)
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
|
|
// First stop should succeed.
|
|
if err := srv.Stop(); err != nil {
|
|
t.Fatalf("First stop failed: %v", err)
|
|
}
|
|
|
|
// Second stop should not panic (socket already removed).
|
|
_ = srv.Stop()
|
|
}
|
|
|
|
func TestProtocolStatus(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer srv.Stop() //nolint:errcheck // test cleanup
|
|
|
|
// Send a status request.
|
|
resp := sendTestRequest(t, sockPath, Request{Action: "status"})
|
|
|
|
if !resp.OK {
|
|
t.Fatalf("Expected OK=true, got error: %s", resp.Error)
|
|
}
|
|
if !resp.Running {
|
|
t.Error("Expected Running=true")
|
|
}
|
|
if resp.ActiveSessions != 0 {
|
|
t.Errorf("Expected 0 active sessions, got %d", resp.ActiveSessions)
|
|
}
|
|
}
|
|
|
|
func TestProtocolUnknownAction(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer srv.Stop() //nolint:errcheck // test cleanup
|
|
|
|
resp := sendTestRequest(t, sockPath, Request{Action: "unknown_action"})
|
|
|
|
if resp.OK {
|
|
t.Fatal("Expected OK=false for unknown action")
|
|
}
|
|
if resp.Error == "" {
|
|
t.Error("Expected error message for unknown action")
|
|
}
|
|
}
|
|
|
|
func TestProtocolCreateSessionMissingProxy(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer srv.Stop() //nolint:errcheck // test cleanup
|
|
|
|
// Create session without proxy_url should fail.
|
|
resp := sendTestRequest(t, sockPath, Request{
|
|
Action: "create_session",
|
|
})
|
|
|
|
if resp.OK {
|
|
t.Fatal("Expected OK=false for missing proxy URL")
|
|
}
|
|
if resp.Error == "" {
|
|
t.Error("Expected error message for missing proxy URL")
|
|
}
|
|
}
|
|
|
|
func TestProtocolCreateSessionTunFailure(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
// Use a nonexistent tun2socks path so TunManager.Start() will fail.
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer srv.Stop() //nolint:errcheck // test cleanup
|
|
|
|
// Create session should fail because tun2socks binary does not exist.
|
|
resp := sendTestRequest(t, sockPath, Request{
|
|
Action: "create_session",
|
|
ProxyURL: "socks5://127.0.0.1:1080",
|
|
})
|
|
|
|
if resp.OK {
|
|
t.Fatal("Expected OK=false when tun2socks is not available")
|
|
}
|
|
if resp.Error == "" {
|
|
t.Error("Expected error message when tun2socks fails")
|
|
}
|
|
|
|
// Verify no session was created.
|
|
if srv.ActiveSessions() != 0 {
|
|
t.Error("Expected 0 active sessions after failed create")
|
|
}
|
|
}
|
|
|
|
func TestProtocolDestroySessionMissingID(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer srv.Stop() //nolint:errcheck // test cleanup
|
|
|
|
resp := sendTestRequest(t, sockPath, Request{
|
|
Action: "destroy_session",
|
|
})
|
|
|
|
if resp.OK {
|
|
t.Fatal("Expected OK=false for missing session ID")
|
|
}
|
|
if resp.Error == "" {
|
|
t.Error("Expected error message for missing session ID")
|
|
}
|
|
}
|
|
|
|
func TestProtocolDestroySessionNotFound(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer srv.Stop() //nolint:errcheck // test cleanup
|
|
|
|
resp := sendTestRequest(t, sockPath, Request{
|
|
Action: "destroy_session",
|
|
SessionID: "nonexistent-session-id",
|
|
})
|
|
|
|
if resp.OK {
|
|
t.Fatal("Expected OK=false for nonexistent session")
|
|
}
|
|
if resp.Error == "" {
|
|
t.Error("Expected error message for nonexistent session")
|
|
}
|
|
}
|
|
|
|
func TestProtocolInvalidJSON(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer srv.Stop() //nolint:errcheck // test cleanup
|
|
|
|
// Send invalid JSON to the server.
|
|
conn, err := net.DialTimeout("unix", sockPath, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("Failed to connect: %v", err)
|
|
}
|
|
defer conn.Close() //nolint:errcheck // test cleanup
|
|
|
|
if _, err := conn.Write([]byte("not valid json\n")); err != nil {
|
|
t.Fatalf("Failed to write: %v", err)
|
|
}
|
|
|
|
// Read error response.
|
|
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
decoder := json.NewDecoder(conn)
|
|
var resp Response
|
|
if err := decoder.Decode(&resp); err != nil {
|
|
t.Fatalf("Failed to decode error response: %v", err)
|
|
}
|
|
|
|
if resp.OK {
|
|
t.Fatal("Expected OK=false for invalid JSON")
|
|
}
|
|
if resp.Error == "" {
|
|
t.Error("Expected error message for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestClientIsRunning(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
client := NewClient(sockPath, true)
|
|
|
|
// Server not started yet.
|
|
if client.IsRunning() {
|
|
t.Error("Expected IsRunning=false when server is not started")
|
|
}
|
|
|
|
// Start the server.
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer srv.Stop() //nolint:errcheck // test cleanup
|
|
|
|
// Now the client should detect the server.
|
|
if !client.IsRunning() {
|
|
t.Error("Expected IsRunning=true when server is running")
|
|
}
|
|
}
|
|
|
|
func TestClientStatus(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer srv.Stop() //nolint:errcheck // test cleanup
|
|
|
|
client := NewClient(sockPath, true)
|
|
resp, err := client.Status()
|
|
if err != nil {
|
|
t.Fatalf("Status failed: %v", err)
|
|
}
|
|
|
|
if !resp.OK {
|
|
t.Fatalf("Expected OK=true, got error: %s", resp.Error)
|
|
}
|
|
if !resp.Running {
|
|
t.Error("Expected Running=true")
|
|
}
|
|
if resp.ActiveSessions != 0 {
|
|
t.Errorf("Expected 0 active sessions, got %d", resp.ActiveSessions)
|
|
}
|
|
}
|
|
|
|
func TestClientDestroySessionNotFound(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer srv.Stop() //nolint:errcheck // test cleanup
|
|
|
|
client := NewClient(sockPath, true)
|
|
err := client.DestroySession("nonexistent-id")
|
|
if err == nil {
|
|
t.Fatal("Expected error for nonexistent session")
|
|
}
|
|
}
|
|
|
|
func TestClientConnectionRefused(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
// No server running.
|
|
client := NewClient(sockPath, true)
|
|
|
|
_, err := client.Status()
|
|
if err == nil {
|
|
t.Fatal("Expected error when server is not running")
|
|
}
|
|
|
|
_, err = client.CreateSession("socks5://127.0.0.1:1080", "")
|
|
if err == nil {
|
|
t.Fatal("Expected error when server is not running")
|
|
}
|
|
|
|
err = client.DestroySession("some-id")
|
|
if err == nil {
|
|
t.Fatal("Expected error when server is not running")
|
|
}
|
|
}
|
|
|
|
func TestProtocolMultipleStatusRequests(t *testing.T) {
|
|
sockPath := testSocketPath(t)
|
|
|
|
srv := NewServer(sockPath, "/nonexistent/tun2socks", true)
|
|
if err := srv.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer srv.Stop() //nolint:errcheck // test cleanup
|
|
|
|
// Send multiple status requests sequentially (each on a new connection).
|
|
for i := 0; i < 5; i++ {
|
|
resp := sendTestRequest(t, sockPath, Request{Action: "status"})
|
|
if !resp.OK {
|
|
t.Fatalf("Request %d: expected OK=true, got error: %s", i, resp.Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProtocolRequestResponseJSON(t *testing.T) {
|
|
// Test that protocol types serialize/deserialize correctly.
|
|
req := Request{
|
|
Action: "create_session",
|
|
ProxyURL: "socks5://127.0.0.1:1080",
|
|
DNSAddr: "1.1.1.1:53",
|
|
SessionID: "test-session",
|
|
}
|
|
|
|
data, err := json.Marshal(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal request: %v", err)
|
|
}
|
|
|
|
var decoded Request
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatalf("Failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
if decoded.Action != req.Action {
|
|
t.Errorf("Action: got %q, want %q", decoded.Action, req.Action)
|
|
}
|
|
if decoded.ProxyURL != req.ProxyURL {
|
|
t.Errorf("ProxyURL: got %q, want %q", decoded.ProxyURL, req.ProxyURL)
|
|
}
|
|
if decoded.DNSAddr != req.DNSAddr {
|
|
t.Errorf("DNSAddr: got %q, want %q", decoded.DNSAddr, req.DNSAddr)
|
|
}
|
|
if decoded.SessionID != req.SessionID {
|
|
t.Errorf("SessionID: got %q, want %q", decoded.SessionID, req.SessionID)
|
|
}
|
|
|
|
resp := Response{
|
|
OK: true,
|
|
SessionID: "abc123",
|
|
TunDevice: "utun7",
|
|
SandboxUser: "_greywall",
|
|
SandboxGroup: "_greywall",
|
|
Running: true,
|
|
ActiveSessions: 1,
|
|
}
|
|
|
|
data, err = json.Marshal(resp)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal response: %v", err)
|
|
}
|
|
|
|
var decodedResp Response
|
|
if err := json.Unmarshal(data, &decodedResp); err != nil {
|
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if decodedResp.OK != resp.OK {
|
|
t.Errorf("OK: got %v, want %v", decodedResp.OK, resp.OK)
|
|
}
|
|
if decodedResp.SessionID != resp.SessionID {
|
|
t.Errorf("SessionID: got %q, want %q", decodedResp.SessionID, resp.SessionID)
|
|
}
|
|
if decodedResp.TunDevice != resp.TunDevice {
|
|
t.Errorf("TunDevice: got %q, want %q", decodedResp.TunDevice, resp.TunDevice)
|
|
}
|
|
if decodedResp.SandboxUser != resp.SandboxUser {
|
|
t.Errorf("SandboxUser: got %q, want %q", decodedResp.SandboxUser, resp.SandboxUser)
|
|
}
|
|
if decodedResp.SandboxGroup != resp.SandboxGroup {
|
|
t.Errorf("SandboxGroup: got %q, want %q", decodedResp.SandboxGroup, resp.SandboxGroup)
|
|
}
|
|
if decodedResp.Running != resp.Running {
|
|
t.Errorf("Running: got %v, want %v", decodedResp.Running, resp.Running)
|
|
}
|
|
if decodedResp.ActiveSessions != resp.ActiveSessions {
|
|
t.Errorf("ActiveSessions: got %d, want %d", decodedResp.ActiveSessions, resp.ActiveSessions)
|
|
}
|
|
}
|
|
|
|
func TestProtocolResponseOmitEmpty(t *testing.T) {
|
|
// Verify omitempty works: error-only response should not include session fields.
|
|
resp := Response{OK: false, Error: "something went wrong"}
|
|
|
|
data, err := json.Marshal(resp)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal: %v", err)
|
|
}
|
|
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
t.Fatalf("Failed to unmarshal to map: %v", err)
|
|
}
|
|
|
|
// These fields should be omitted due to omitempty.
|
|
for _, key := range []string{"session_id", "tun_device", "sandbox_user", "sandbox_group"} {
|
|
if _, exists := raw[key]; exists {
|
|
t.Errorf("Expected %q to be omitted from JSON, but it was present", key)
|
|
}
|
|
}
|
|
|
|
// Error should be present.
|
|
if _, exists := raw["error"]; !exists {
|
|
t.Error("Expected 'error' field in JSON")
|
|
}
|
|
}
|
|
|
|
func TestGenerateSessionID(t *testing.T) {
|
|
// Verify session IDs are unique and properly formatted.
|
|
seen := make(map[string]bool)
|
|
for i := 0; i < 100; i++ {
|
|
id, err := generateSessionID()
|
|
if err != nil {
|
|
t.Fatalf("generateSessionID failed: %v", err)
|
|
}
|
|
if len(id) != 32 { // 16 bytes = 32 hex chars
|
|
t.Errorf("Expected 32-char hex ID, got %d chars: %q", len(id), id)
|
|
}
|
|
if seen[id] {
|
|
t.Errorf("Duplicate session ID: %s", id)
|
|
}
|
|
seen[id] = true
|
|
}
|
|
}
|
|
|
|
// sendTestRequest connects to the server, sends a JSON request, and returns
|
|
// the JSON response. This is a low-level helper that bypasses the Client
|
|
// to test the raw protocol.
|
|
func sendTestRequest(t *testing.T, sockPath string, req Request) Response {
|
|
t.Helper()
|
|
|
|
conn, err := net.DialTimeout("unix", sockPath, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("Failed to connect to server: %v", err)
|
|
}
|
|
defer conn.Close() //nolint:errcheck // test cleanup
|
|
|
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
|
|
|
encoder := json.NewEncoder(conn)
|
|
if err := encoder.Encode(req); err != nil {
|
|
t.Fatalf("Failed to encode request: %v", err)
|
|
}
|
|
|
|
decoder := json.NewDecoder(conn)
|
|
var resp Response
|
|
if err := decoder.Decode(&resp); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
return resp
|
|
}
|