diff --git a/.gitignore b/.gitignore index f706eaa..0da5764 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ config.json *-config.json .DS_Store .cursor/ +CLAUDE.md +AGENTS.md *.txt __blobstorage__/ __azurite*.json \ No newline at end of file diff --git a/README.md b/README.md index af44828..039daf3 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,32 @@ -# ProxyBlob +# ProxyBlob v2 + +๐ŸŽ‰ New release ! _see [CHANGELOG](#changelog)_ ๐ŸŽ‰

- ProxyBlob logo + ProxyBlob v2 logo

-

SOCKS proxy over Azure Storage Blob service

+

SOCKS proxy over Azure Storage service

## Overview -ProxyBlob is a tool designed to create SOCKS proxy tunnels through Azure Blob Storage service. This is particularly useful in environments where direct network connectivity is restricted but `*.blob.core.windows.net` is accessible. +ProxyBlob is a tool designed to create SOCKS proxy tunnels through Azure Storage services. This is particularly useful in environments where direct network connectivity is restricted but `*.core.windows.net` is accessible. The system consists of two components: -1. **Proxy Server**: Runs on your local machine and provides a SOCKS interface for your applications -2. **Agent**: Runs inside the target network and communicates with the proxy through Azure Blob Storage +1. **Proxy Server**: Runs on your local machine or a remote server and provides a SOCKS interface for your applications +2. **Agent**: Runs inside the target network and communicates with the proxy through Azure Storage services ## Features - SOCKS5 protocol (CONNECT and UDP ASSOCIATE) -- Communication through Azure Blob Storage +- Communication through Azure Storage services (thanks to [aznet](https://github.com/atsika/aznet)!) - Interactive CLI with auto-completion - Multiple agent management -- Local proxy server +- Local or remote proxy server ## Prerequisites -- Go 1.23 or higher +- Go 1.25 or higher - An Azure Storage Account ### Storage Account @@ -108,7 +110,7 @@ Press on the `[Azurite Blob Service]` and the service should be running. For Docker, you will have to pull the image and run it: -``` +```bash docker pull mcr.microsoft.com/azure-storage/azurite docker run -p 10000:10000 mcr.microsoft.com/azure-storage/azurite ``` @@ -137,12 +139,28 @@ Create a `config.json` file based on the [example](example_config.json) with you ```json { - "storage_url": "http://localhost:10000/", // omit if using Azure - "storage_account_name": "your-storage-account-name", - "storage_account_key": "your-storage-account-key" + "listeners": [ + { + "name": "blob-listener", + "driver": "azblob", + "address": "https://proxyblob.blob.core.windows.net", + "storage_account": "proxyblob", + "storage_account_key": "your_account_key" + }, + { + "name": "queue-listener", + "driver": "azqueue", + "address": "http://127.0.0.1:10001", + "storage_account": "devstoreaccount1", + "storage_account_key": "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==" + } + ] } + ``` +You can configure multiple listeners and manage them in the proxy CLI. + ## Usage ### Starting the Proxy Server @@ -155,43 +173,46 @@ This will start an interactive CLI with the following commands: ``` Commands: - clear clear the screen - create, new create a new agent container and generate its connection string - delete, rm delete an existing agent container - exit exit the shell - help use 'help [command]' for command help - list, ls list all existing agent containers - select, use select an agent for subsequent commands - start, proxy start SOCKS proxy server - stop stop running proxy for the selected agent + agent manage agents + clear clear the screen + exit exit the shell + help use 'help [command]' for command help + listener manage listeners + new generate a new connection string for an agent ``` -Once the proxy server is running, you can create a new agent container by using the `create` command. +Once the proxy server is running, start a listener. The started listener automatically becomes the default for subsequent commands. ``` -proxyblob ยป create -16:28:37 INF Agent container created successfully container_id=5f5250e9-5518-4682-90ea-f61abf797654 -16:28:37 INF Connection string generated connection_string=aHR0cDovL2xvY2FsaG9zdDoxMDAwMC9kZXZzdG9yZWFjY291bnQxLzVmNTI1MGU5LTU1MTgtNDY4Mi05MGVhLWY2MWFiZjc5NzY1ND9zZT0yMDI1LTA0LTI0VDE0JTNBMjglM0EzN1omc2lnPXpjNUNVYVZKJTJGS1duY3RtbnlNZ0clMkZZNkNrRzZHYXJzMXRFTXkxR0ZiTVVZJTNEJnNwPXJ3JnNwcj1odHRwcyUyQ2h0dHAmc3I9YyZzdD0yMDI1LTA0LTE3VDE0JTNBMjMlM0EzN1omc3Y9MjAyMC0xMC0wMg +proxyblob ยป listener start blob-local-listener +21:23:25 INF Aznet listener started and set as default addr=http://127.0.0.1:10000 driver=azblob listener_id=blob-local-listener ``` -Use the connection string generated with the agent (see below [Starting the Agent](#starting-the-agent)). If the agent connects successfully, you should see the โ€œAgent Infoโ€ column filled in when you list the agents with the `list` command. +Then you can generate a connection string by using the `new` command. ``` -proxyblob ยป list -โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ CONTAINER ID โ”‚ AGENT INFO โ”‚ PROXY PORT โ”‚ FIRST SEEN โ”‚ LAST SEEN โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ 5f5250e9-5518-4682-90ea-f61abf797654 โ”‚ atsika@qb โ”‚ โ”‚ 2025-04-17 14:28:37 โ”‚ 2025-04-17 14:28:37 โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +proxyblob ยป new +21:25:10 INF Connection string generated connection_string=YXpibG9ifGh0dHA6Ly8xMjcuMC4wLjE6MTAwMDAvZGV2c3RvcmVhY2NvdW50MT9oYW5kc2hha2U9YzJVOU1qQXlOaTB3TWkweE5sUXlNQ1V6UVRJMUpUTkJNVEJhSm5OcFp6MDRlR3ByVjJFeVNXdHhWWEJxUlU1cGJIVmtOVGhuWXpkNU5raDNXWEZNUmpZNVFrNDVaMUphT1ZNMEpUTkVKbk53UFdGamR5WnpjSEk5YUhSMGNITWxNa05vZEhSd0puTnlQV01tYzNROU1qQXlOaTB3TWkweE5WUXlNQ1V6UVRJd0pUTkJNVEJhSm5OMlBUSXdNalV0TVRFdE1EVSUzRCZ0b2tlbj1jMlU5TWpBeU5pMHdNaTB4TmxReU1DVXpRVEkxSlROQk1UQmFKbk5wWnowM2NXZ3dZVWgxV1dKTE1rcGlOVVYyVG5WTk1XdEpTMFJFVTFkVGNFWlllVXd5ZUV0dWRYaG9jVWc0SlRORUpuTndQWEpzSm5Od2NqMW9kSFJ3Y3lVeVEyaDBkSEFtYzNJOVl5WnpkRDB5TURJMkxUQXlMVEUxVkRJd0pUTkJNakFsTTBFeE1Gb21jM1k5TWpBeU5TMHhNUzB3TlElM0QlM0Q listener_id=blob-local-listener ``` -Select the agent using `select ` and start the proxy listener (by default it listens on localhost:1080) by using the `start` command. +Use the generated connection string with the agent (see below [Starting the Agent](#starting-the-agent)). If the agent connects successfully, you should see its identity (`user@host`) in the "Info" column when you list agents. ``` -atsika@qb ยป select 5f5250e9-5518-4682-90ea-f61abf797654 -17:17:51 INF Agent selected agent=atsika@qb -atsika@qb ยป start -17:17:58 INF Proxy started successfully agent=atsika@qb port=1080 +proxyblob ยป agent ls +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ AGENT ID โ”‚ INFO โ”‚ LISTENER โ”‚ PROXY PORT โ”‚ CONNECTED AT โ”‚ LAST SEEN โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ 7b5af883-7cc7-45a4-8599-da906753005d โ”‚ atsika@mac.home โ”‚ blob-local-listener โ”‚ 1080 โ”‚ 2026-02-15 21:50:04 โ”‚ 2m ago โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +``` + +Select the agent using `agent select ` and start the proxy listener (by default it listens on localhost:1080) by using the `agent start` command. + +``` +proxyblob ยป agent select 7b5af883-7cc7-45a4-8599-da906753005d +22:10:40 INF Agent selected agent_id=7b5af883-7cc7-45a4-8599-da906753005d +7b5af883 ยป agent start +22:10:46 INF Proxy started agent_id=7b5af883-7cc7-45a4-8599-da906753005d port=1080 ``` You can now use for example [proxychains](https://github.com/rofl0r/proxychains-ng) to tunnel the traffic through the SOCKS proxy. @@ -269,7 +290,7 @@ graph TB TransportA -->|Decode Packets| SocksHandler SocksHandler -->|Process Commands| CommandProcessor - %% Command Processing - Fixed syntax + %% Command Processing subgraph "Command Processing" Connect["CONNECT"] Bind["TODO: BIND"] @@ -341,14 +362,17 @@ sequenceDiagram **Why does my agent immediately stop running ?** There might be several reasons why your agent stops immediately after you run it. Check its exit code: + ```sh # Bash echo $? ``` + ```cmd REM CMD echo %ERRORLEVEL% ``` + ```pwsh #ย PowerShell echo $LastExitCode @@ -362,8 +386,6 @@ Each exit code describes why the agent stopped running: | 1 | The context has been canceled | | 2 | The connection string is missing | | 3 | The connection string is invalid or expired | -| 4 | The agent failed to write its metadata | -| 5 | The agent container is not found | If you encounter issues: @@ -374,10 +396,41 @@ If you encounter issues: ## TODO -- BIND command implementation (currently not working) -- Enhanced error handling and recovery -- Improve proxy speed +- BIND command (not implemented yet) +- Improve proxy speed even more + +## CHANGELOG + +**ProxyBlob v2 (aznet boosted) - 16/02/2026:** + +- Complete architecture rewrite using aznet networking layer +- Multiple Azure Storage backends (Blob, Queue, Table Storage) +- Last seen timestamps for connected agents +- Enhanced connection management and lifecycle +- Better error handling and recovery mechanisms +- Improved polling efficiency with adaptive intervals +- Automatic connection cleanup and resource management +- Significantly higher throughput and lower latency +- Cost optimization through backend selection +- Multi-listener configuration support + +**ProxyBlob public release - 29/04/2025:** + +- SOCKS5 protocol (CONNECT and UDP ASSOCIATE) +- Reverse client-server architecture +- Azure Blob Storage communication +- Interactive CLI with auto-completion +- Multi-agent management +- Local or remote proxy server +- Container-based communication +- Connection string authentication +- Error handling +- Azurite support for local development ## License -[GNU GPLv3 License](LICENSE) \ No newline at end of file +[GNU GPLv3 License](LICENSE) + +--- + +Made with โค๏ธ by [@_atsika](https://x.com/_atsika) diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 3e61df2..14d8b56 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -2,13 +2,11 @@ package main import ( - "bytes" "context" "encoding/base64" "errors" "flag" "fmt" - "net/url" "os" "os/signal" "os/user" @@ -16,13 +14,12 @@ import ( "syscall" "time" - "github.com/Azure/azure-storage-blob-go/azblob" "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "proxyblob/pkg/protocol" proxy "proxyblob/pkg/proxy/socks" - "proxyblob/pkg/transport" + + "github.com/atsika/aznet" ) // Exit codes. @@ -31,61 +28,44 @@ const ( ErrContextCanceled = 1 // context canceled ErrNoConnectionString = 2 // missing connection string ErrConnectionStringError = 3 // invalid connection string - ErrInfoBlobError = 4 // info blob write failed - ErrContainerNotFound = 5 // container not found ) // ConnString holds the Azure connection string. // Can be set at compile time or via command line flag. var ConnString string -// Blob names for proxy-agent communication. -const ( - InfoBlobName = "info" // agent metadata - RequestBlobName = "request" // proxy-to-agent traffic - ResponseBlobName = "response" // agent-to-proxy traffic -) - -// InfoKey defines the XOR encryption key for agent information -// Security Note: Changing this key requires synchronized updates on both proxy and agent -var ( - InfoKey = []byte{0xDE, 0xAD, 0xB1, 0x0B} -) - -// Agent manages proxy operations and blob storage communication. +// Agent manages proxy operations and aznet communication. type Agent struct { - ContainerURL azblob.ContainerURL // Azure container access - Handler *proxy.SocksHandler // SOCKS handler + Handler *proxy.SocksHandler // SOCKS handler } // NewAgent creates an agent from a connection string. func NewAgent(ctx context.Context, connString string) (*Agent, int) { - storageURL, containerID, sasToken, errCode := ParseConnectionString(connString) + // Decode and parse the connection string + driver, address, errCode := ParseConnectionString(connString) if errCode != Success { return nil, errCode } - pipeline := azblob.NewPipeline( - azblob.NewAnonymousCredential(), - azblob.PipelineOptions{}, - ) - - fullURL := fmt.Sprintf("%s/%s?%s", storageURL, containerID, sasToken) - containerURL, err := url.Parse(fullURL) + // Dial to the proxy using aznet with ULTRA-aggressive polling (WARNING: High API costs!) + conn, err := aznet.Dial(driver, address, aznet.WithContext(ctx), aznet.WithFastPoll(time.Millisecond)) if err != nil { + log.Error().Err(err).Msg("Failed to dial proxy") return nil, ErrConnectionStringError } + log.Info().Msg("Connected to proxy") - container := azblob.NewContainerURL(*containerURL, pipeline) - blobTransport := transport.NewBlobTransport( - container.NewBlockBlobURL(RequestBlobName), // read - container.NewBlockBlobURL(ResponseBlobName), // write - ) - handler := proxy.NewSocksHandler(ctx, blobTransport) + // Send identity (user@host) as first raw message before protocol starts + identity := getIdentity() + if _, err := conn.Write([]byte(identity)); err != nil { + log.Warn().Err(err).Msg("Failed to send identity") + } + + // Create SOCKS handler with direct connection (no transport wrapper) + handler := proxy.NewSocksHandler(ctx, conn) agent := &Agent{ - ContainerURL: container, - Handler: handler, + Handler: handler, } return agent, Success @@ -93,15 +73,6 @@ func NewAgent(ctx context.Context, connString string) (*Agent, int) { // Start begins processing proxy requests. func (a *Agent) Start(ctx context.Context) int { - // Push agent information to blob storage - if err := a.WriteInfoBlob(ctx); err != Success { - a.Stop() - return ErrContainerNotFound - } - - // Start the container monitoring goroutine - go a.healthCheck(ctx) - // Start the handler a.Handler.Start("") @@ -121,120 +92,41 @@ func (a *Agent) Stop() { a.Handler.Stop() } -// healthCheck verifies container existence every 30s and stops the agent if it's unavailable. -func (a *Agent) healthCheck(ctx context.Context) { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - blobURL := a.ContainerURL.NewBlockBlobURL(InfoBlobName) - _, err := blobURL.GetProperties(ctx, azblob.BlobAccessConditions{}, azblob.ClientProvidedKeyOptions{}) - if err != nil { - if storageErr, ok := err.(azblob.StorageError); ok { - if storageErr.ServiceCode() == azblob.ServiceCodeContainerNotFound || - storageErr.ServiceCode() == azblob.ServiceCodeContainerBeingDeleted { - a.Stop() - return - } - } - } - } - } -} - -// WriteInfoBlob updates agent metadata. -func (a *Agent) WriteInfoBlob(ctx context.Context) int { - info := GetCurrentInfo() - encryptedInfo := protocol.Xor([]byte(info), InfoKey) - - blobURL := a.ContainerURL.NewBlockBlobURL(InfoBlobName) - _, err := blobURL.Upload( - ctx, - bytes.NewReader(encryptedInfo), - azblob.BlobHTTPHeaders{ContentType: "text/plain"}, - azblob.Metadata{}, - azblob.BlobAccessConditions{}, - azblob.DefaultAccessTier, - nil, - azblob.ClientProvidedKeyOptions{}, - azblob.ImmutabilityPolicyOptions{}, - ) - - if err != nil { - // Check for context cancellation first - if errors.Is(ctx.Err(), context.Canceled) { - return ErrContextCanceled - } - - // Check for container deletion - if storageErr, ok := err.(azblob.StorageError); ok { - if storageErr.ServiceCode() == azblob.ServiceCodeContainerNotFound || - storageErr.ServiceCode() == azblob.ServiceCodeContainerBeingDeleted { - return ErrContainerNotFound - } - // Other storage errors - return ErrInfoBlobError - } - - return ErrInfoBlobError - } - - return Success -} - -// ParseConnectionString extracts storage URL, container ID and SAS token from a connection string. -func ParseConnectionString(connString string) (string, string, string, int) { +// ParseConnectionString extracts the driver and aznet address from a connection string. +func ParseConnectionString(connString string) (string, string, int) { // Check for empty string first if connString == "" { - return "", "", "", ErrNoConnectionString + return "", "", ErrNoConnectionString } // Try to decode the base64 encoded string decoded, err := base64.RawStdEncoding.DecodeString(connString) if err != nil { - return "", "", "", ErrConnectionStringError + return "", "", ErrConnectionStringError } - // Parse the URL and extract components - u, err := url.Parse(string(decoded)) - if err != nil { - return "", "", "", ErrConnectionStringError + // Parse the string - it might be in the new format: driver|address + // or the old format: address (where driver is extracted from scheme) + str := string(decoded) + if strings.Contains(str, "|") { + parts := strings.SplitN(str, "|", 2) + return parts[0], parts[1], Success } - // Extract path components and query string - path := strings.TrimPrefix(u.Path, "/") - if path == "" { - return "", "", "", ErrConnectionStringError - } - - if u.RawQuery == "" { - return "", "", "", ErrConnectionStringError - } - - // Return the storage URL, container ID and SAS token - storageURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host) - return storageURL, path, u.RawQuery, Success + return "", "", ErrConnectionStringError } -// GetCurrentInfo returns username@hostname. -func GetCurrentInfo() string { +// getIdentity returns "user@hostname" for agent identification. +func getIdentity() string { + username := "unknown" + if u, err := user.Current(); err == nil { + username = u.Username + } hostname, err := os.Hostname() if err != nil { hostname = "unknown" } - - currentUser, err := user.Current() - if err != nil { - currentUser = &user.User{ - Username: "unknown", - } - } - - return fmt.Sprintf("%s@%s", currentUser.Username, hostname) + return fmt.Sprintf("%s@%s", username, hostname) } // init configures logging with zerolog diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index cdb3cda..0e76aaa 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -1,4 +1,4 @@ -// Package main implements the SOCKS proxy server. +// Package main implements the SOCKS proxy server using aznet. package main import ( @@ -6,82 +6,88 @@ import ( "encoding/base64" "encoding/json" "fmt" - "io" "net" "net/url" "os" "path/filepath" - "strings" + "strconv" "sync" "time" - "github.com/Azure/azure-storage-blob-go/azblob" "github.com/desertbit/grumble" "github.com/google/uuid" "github.com/jedib0t/go-pretty/table" "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "proxyblob/pkg/protocol" proxy "proxyblob/pkg/proxy/server" - "proxyblob/pkg/transport" + + "github.com/atsika/aznet" ) // CLI banner with version. const banner = ` - ____ ____ _ _ - | _ \ _ __ _____ ___ _| __ )| | ___ | |__ - | |_) | '__/ _ \ \/ / | | | _ \| |/ _ \| '_ \ - | __/| | | (_) > <| |_| | |_) | | (_) | |_) | - |_| |_| \___/_/\_\\__, |____/|_|\___/|_.__/ - |___/ + ____ ____ _ _ + | _ \ _ __ _____ ___ _| __ )| | ___ | |__ + | |_) | '__/ _ \ \/ / | | | _ \| |/ _ \| '_ \ + | __/| | | (_) > <| |_| | |_) | | (_) | |_) | + |_| |_| \___/_/\_\\__, |____/|_|\___/|_.__/ + |___/ - SOCKS Proxy over Azure Blob Storage (v1.0) - ------------------------------------------ + SOCKS Proxy over More Azure Storage (v2.0 - aznet) + -------------------------------------------------- ` -// Blob names for proxy-agent communication. -const ( - InfoBlobName = "info" // agent metadata - RequestBlobName = "request" // proxy-to-agent traffic - ResponseBlobName = "response" // agent-to-proxy traffic -) +// ListenerConfig holds Azure Storage credentials for a single listener. +type ListenerConfig struct { + Name string `json:"name"` // listener name/ID + Driver string `json:"driver"` // azblob, aztable, or azqueue + Address string `json:"address"` // endpoint URL (e.g. http://127.0.0.1:10001) + StorageAccountName string `json:"storage_account"` // account ID + StorageAccountKey string `json:"storage_account_key"` // access key +} -// InfoKey defines the XOR encryption key for agent information -// Security Note: Changing this key requires synchronized updates on both proxy and agent -var ( - InfoKey = []byte{0xDE, 0xAD, 0xB1, 0x0B} -) - -// Config holds Azure Storage credentials. +// Config holds multiple listener configurations. type Config struct { - StorageAccountName string `json:"storage_account_name"` // account ID - StorageAccountKey string `json:"storage_account_key"` // access key - StorageURL string `json:"storage_url,omitempty"` // custom endpoint (for development purposes) + Listeners []ListenerConfig `json:"listeners"` // array of listener configurations } -// StorageManager handles Azure Storage operations. -type StorageManager struct { - ServiceURL *azblob.ServiceURL // storage endpoint - SharedKeyCredential *azblob.SharedKeyCredential // auth credentials +// ListenerState tracks the state of a listener. +type ListenerState struct { + Config *ListenerConfig // listener configuration + Listener *aznet.Listener // aznet listener + ListenAddr string // address where listener is bound + Running bool // whether listener is running + StartedAt time.Time // when listener was started + CancelFunc context.CancelFunc // function to cancel the accept loop context } -// ContainerInfo tracks proxy agent metadata. -type ContainerInfo struct { - ID string // container ID - AgentInfo string // username@hostname - ProxyPort string // SOCKS port - CreatedAt time.Time // creation time - LastActivity time.Time // last operation +// AgentConnection tracks a connected agent. +type AgentConnection struct { + ID string // connection ID + Conn net.Conn // aznet connection + ListenerID string // ID of the listener this agent connected to + Info string // agent identity (user@host) + LastSeen time.Time // last message received from agent + CreatedAt time.Time // connection time +} + +// AgentInfo tracks connected agent metadata for display. +type AgentInfo struct { + *AgentConnection + ProxyPort string // SOCKS port (if proxy is running) } // Global state. var ( - config *Config // app config - storageManager *StorageManager // storage access - selectedAgent string // current agent - runningProxies sync.Map // active proxies + config *Config // app config + app *grumble.App // grumble app instance for prompt updates + listeners sync.Map // active listeners: map[listenerID]*ListenerState + connectedAgents sync.Map // connected agents: map[connID]*AgentConnection + runningProxies sync.Map // active proxies: map[connID]*proxy.ProxyServer + selectedAgent string // currently selected agent ID + selectedListener string // currently selected/default listener ID ) // LoadConfig reads and parses config file. @@ -108,605 +114,931 @@ func LoadConfig(configPath string) (*Config, error) { return nil, fmt.Errorf("failed to read config file %s: %v", absPath, err) } - config := new(Config) - if err := json.Unmarshal(data, config); err != nil { + cfg := new(Config) + if err := json.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("failed to parse config file %s: %v", absPath, err) } // Validate configuration - if err := config.Validate(); err != nil { + if err := cfg.Validate(); err != nil { return nil, err } - return config, nil + return cfg, nil } // Validate checks required config fields. -func (config *Config) Validate() error { - if config.StorageAccountName == "" { - return fmt.Errorf("storage_account_name is required") +func (c *Config) Validate() error { + if len(c.Listeners) == 0 { + return fmt.Errorf("at least one listener configuration is required") } - if config.StorageAccountKey == "" { - return fmt.Errorf("storage_account_key is required") + + // Track listener names to ensure uniqueness + listenerNames := make(map[string]bool) + + for i, listener := range c.Listeners { + if err := listener.Validate(); err != nil { + return fmt.Errorf("listener[%d]: %v", i, err) + } + + // Check for duplicate names + if listenerNames[listener.Name] { + return fmt.Errorf("listener[%d]: duplicate listener name '%s'", i, listener.Name) + } + listenerNames[listener.Name] = true } + return nil } -// NewStorageManager creates Azure Storage client. -func NewStorageManager(config *Config) (*StorageManager, error) { - // Create credentials using the storage account name and key - credential, err := azblob.NewSharedKeyCredential( - config.StorageAccountName, - config.StorageAccountKey, - ) - if err != nil { - return nil, fmt.Errorf("failed to create storage credentials: %v", err) +// Validate checks required listener config fields. +func (lc *ListenerConfig) Validate() error { + if lc.Name == "" { + return fmt.Errorf("name is required") + } + if lc.Driver == "" { + return fmt.Errorf("driver is required (azblob, aztable, or azqueue)") + } + if lc.Address == "" { + return fmt.Errorf("address is required") + } + if lc.StorageAccountName == "" { + return fmt.Errorf("storage_account is required") + } + if lc.StorageAccountKey == "" { + return fmt.Errorf("storage_account_key is required") } - // Create a pipeline for storage operations - pipeline := azblob.NewPipeline( - credential, - azblob.PipelineOptions{}, - ) - - // Build the service URL for the storage account - var serviceURL *url.URL - if config.StorageURL != "" { - // Use the custom storage URL provided in the config (for Azurite support) - serviceURL, err = url.Parse(config.StorageURL) - if err != nil { - return nil, fmt.Errorf("failed to parse storage URL: %v", err) - } - serviceURL = serviceURL.JoinPath(config.StorageAccountName) - } else { - // Use the default Azure Storage URL format - serviceURL, err = url.Parse(fmt.Sprintf("https://%s.blob.core.windows.net/", config.StorageAccountName)) - if err != nil { - return nil, fmt.Errorf("failed to parse service URL: %v", err) - } - } - - service := azblob.NewServiceURL(*serviceURL, pipeline) - - return &StorageManager{ - ServiceURL: &service, - SharedKeyCredential: credential, - }, nil + return nil } -// CreateAgentContainer creates a new container for agent communication. -// Returns the container ID and connection string that should be provided to the agent. -func (sm *StorageManager) CreateAgentContainer(expiry time.Duration) (string, string, error) { - // Generate a unique ID for the container - containerID := uuid.New().String() - containerURL := sm.ServiceURL.NewContainerURL(containerID) - - ctx := context.Background() - - // Create the container with private access level - _, err := containerURL.Create(ctx, azblob.Metadata{}, azblob.PublicAccessNone) - if err != nil { - return "", "", fmt.Errorf("failed to create container: %v", err) - } - - // Initialize the required blobs - blobNames := []string{InfoBlobName, RequestBlobName, ResponseBlobName} - for _, blobName := range blobNames { - blobURL := containerURL.NewBlockBlobURL(blobName) - - // Create an empty blob - it will be populated later by the agent or proxy - _, err := blobURL.Upload( - ctx, - strings.NewReader(""), // Empty content initially - azblob.BlobHTTPHeaders{ - ContentType: "application/octet-stream", - }, - azblob.Metadata{ - "created": time.Now().UTC().Format(time.RFC3339), - }, - azblob.BlobAccessConditions{}, - azblob.DefaultAccessTier, - azblob.BlobTagsMap{}, // No initial tags - azblob.ClientProvidedKeyOptions{}, // No client-provided encryption - azblob.ImmutabilityPolicyOptions{}, // No immutability policy - ) - - if err != nil { - // If blob creation fails, attempt to clean up the container - if _, delErr := containerURL.Delete(ctx, azblob.ContainerAccessConditions{}); delErr != nil { - return "", "", fmt.Errorf("failed to delete container after blob creation failed: %v", delErr) - } - return "", "", fmt.Errorf("failed to create %s blob: %v", blobName, err) +// StartListener creates and starts an aznet listener for the given listener config. +func StartListener(listenerID string) error { + // Check if listener already exists + if val, ok := listeners.Load(listenerID); ok { + state := val.(*ListenerState) + if state.Running { + return fmt.Errorf("listener '%s' is already running", listenerID) } } - // Generate a SAS token for the container - sasToken, err := sm.GenerateSASToken(containerID, expiry) - if err != nil { - // If SAS token generation fails, clean up the container - if _, delErr := containerURL.Delete(ctx, azblob.ContainerAccessConditions{}); delErr != nil { - return "", "", fmt.Errorf("failed to delete container after SAS token generation failed") + // Find the listener config + var listenerConfig *ListenerConfig + for i := range config.Listeners { + if config.Listeners[i].Name == listenerID { + listenerConfig = &config.Listeners[i] + break } - return "", "", fmt.Errorf("failed to generate SAS token") } - connectionString, _ := url.Parse(storageManager.ServiceURL.String()) - connectionString = connectionString.JoinPath(containerID) - connString := connectionString.String() + "?" + sasToken + if listenerConfig == nil { + return fmt.Errorf("listener '%s' not found in configuration", listenerID) + } - return containerID, connString, nil + // Validate the listener config + if err := listenerConfig.Validate(); err != nil { + return fmt.Errorf("invalid listener config: %v", err) + } + + // Build the listen address using url.URL for proper escaping + u, err := url.Parse(listenerConfig.Address) + if err != nil { + return fmt.Errorf("invalid address format: %v", err) + } + + listenURL := &url.URL{ + Scheme: u.Scheme, + User: url.UserPassword(listenerConfig.StorageAccountName, listenerConfig.StorageAccountKey), + Host: u.Host, + Path: u.Path, + } + listenAddr := listenURL.String() + + log.Debug().Str("listener_id", listenerID).Str("listen_addr", listenAddr).Msg("Starting aznet listener") + + // Use ultra-aggressive polling (1ms) - this will increase Azure API costs! + ctx, cancel := context.WithCancel(context.Background()) + l, err := aznet.Listen(listenerConfig.Driver, listenAddr, aznet.WithContext(ctx)) + if err != nil { + cancel() + return fmt.Errorf("failed to start aznet listener: %v", err) + } + + listener, ok := l.(*aznet.Listener) + if !ok { + cancel() + l.Close() + return fmt.Errorf("failed to cast to aznet.Listener") + } + + // Store listener state + state := &ListenerState{ + Config: listenerConfig, + Listener: listener, + ListenAddr: listenAddr, + Running: true, + StartedAt: time.Now(), + CancelFunc: cancel, + } + listeners.Store(listenerID, state) + + // Set this listener as the default when started + selectedListener = listenerID + + log.Info().Str("listener_id", listenerID).Str("driver", listenerConfig.Driver).Str("addr", listenerConfig.Address).Msg("Aznet listener started and set as default") + + // Start accepting agent connections for this listener + go AcceptAgentLoopForListener(ctx, listenerID, listener) + + return nil } -// GenerateSASToken creates a Shared Access Signature token for container access. -// The token provides limited-time read/write access to specific container resources. -func (sm *StorageManager) GenerateSASToken(containerName string, expiry time.Duration) (string, error) { - // Start time is 5 minutes before now to avoid clock skew issues - startTime := time.Now().UTC().Add(-5 * time.Minute) - - // Set expiry time (default 7 days) - expiryTime := time.Now().UTC().Add(expiry) - - // Define the permissions for the SAS token - permissions := azblob.ContainerSASPermissions{ - Read: true, - Write: true, +// StopListener stops an aznet listener. +func StopListener(listenerID string) error { + val, ok := listeners.Load(listenerID) + if !ok { + return fmt.Errorf("listener '%s' not found", listenerID) } - // Generate the SAS signature - sasQueryParams, err := azblob.BlobSASSignatureValues{ - Protocol: azblob.SASProtocolHTTPSandHTTP, - StartTime: startTime, - ExpiryTime: expiryTime, - ContainerName: containerName, - Permissions: permissions.String(), - }.NewSASQueryParameters(sm.SharedKeyCredential) - - if err != nil { - return "", fmt.Errorf("failed to create SAS query parameters: %v", err) + state := val.(*ListenerState) + if !state.Running { + return fmt.Errorf("listener '%s' is not running", listenerID) } - // Convert the SAS query parameters to a string - sasToken := sasQueryParams.Encode() - return sasToken, nil -} - -// ListAgentContainers retrieves information about all agent containers. -// It fetches container metadata and agent information, sorted by creation time. -func (sm *StorageManager) ListAgentContainers(ctx context.Context) ([]ContainerInfo, error) { - var containers []ContainerInfo - - // List all containers in the storage account - for marker := (azblob.Marker{}); marker.NotDone(); { - // Get a segment of containers (up to 100 at a time) - listResponse, err := sm.ServiceURL.ListContainersSegment(ctx, marker, azblob.ListContainersSegmentOptions{ - Prefix: "", - MaxResults: 0, - }) - - if err != nil { - return nil, fmt.Errorf("failed to list containers: %v", err) + // First, send FIN messages to all agents while listener is still open + // This allows agents to gracefully receive the shutdown notification + connectedAgents.Range(func(key, value interface{}) bool { + agent := value.(*AgentConnection) + if agent.ListenerID == listenerID { + // Try to send FIN message using CloseWrite if supported + // This allows agents to gracefully handle the shutdown + if closeWriter, ok := agent.Conn.(interface { + CloseWrite() error + }); ok { + _ = closeWriter.CloseWrite() + } } + return true + }) - // Update marker for next iteration - marker = listResponse.NextMarker + // Wait for FIN messages to be sent and propagate through Azure storage + // This gives agents time to receive the shutdown notification + time.Sleep(200 * time.Millisecond) - // Process each container - for _, containerItem := range listResponse.ContainerItems { - // Create container URL for accessing blobs - containerURL := sm.ServiceURL.NewContainerURL(containerItem.Name) + // Now cancel the accept loop context + if state.CancelFunc != nil { + state.CancelFunc() + } - // Try to get the info blob - blobURL := containerURL.NewBlockBlobURL(InfoBlobName) - downloadResponse, err := blobURL.Download(ctx, 0, azblob.CountToEnd, azblob.BlobAccessConditions{}, false, azblob.ClientProvidedKeyOptions{}) + // Close the listener after FIN messages have been sent + if state.Listener != nil { + state.Listener.Close() + } - // Skip containers that don't have our expected structure - if err != nil { - continue - } + // Update state + state.Running = false + state.Listener = nil + state.CancelFunc = nil - // Read the info blob content - bodyReader := downloadResponse.Body(azblob.RetryReaderOptions{MaxRetryRequests: 3}) - agentInfo, err := io.ReadAll(bodyReader) - bodyReader.Close() - if err != nil { - log.Warn().Err(err).Str("container", containerItem.Name).Msg("Failed to read info blob") - continue - } - agentInfo = protocol.Xor(agentInfo, InfoKey) + // Clear default selection if this was the selected listener + if selectedListener == listenerID { + selectedListener = "" + log.Info().Msg("Default listener cleared (listener was stopped)") + } - // Get last activity from response blob - responseBlob := containerURL.NewBlockBlobURL(ResponseBlobName) - responseProps, err := responseBlob.GetProperties(ctx, azblob.BlobAccessConditions{}, azblob.ClientProvidedKeyOptions{}) + // Now fully close all connections and clean up + connectedAgents.Range(func(key, value interface{}) bool { + agent := value.(*AgentConnection) + if agent.ListenerID == listenerID { + agentID := key.(string) - // Get the last modified time, defaulting to container creation time if not available - var lastActivity time.Time - if err != nil { - lastActivity = containerItem.Properties.LastModified - } else { - lastActivity = responseProps.LastModified() - } - - // Check if the container has an active proxy - var proxyPort string - if value, running := runningProxies.Load(containerItem.Name); running { - // Try to get the port from the server object - if server, ok := value.(*proxy.ProxyServer); ok && server.Listener != nil { - _, portStr, err := net.SplitHostPort(server.Listener.Addr().String()) - if err == nil { - proxyPort = portStr - } + // Stop proxy if running + if val, ok := runningProxies.LoadAndDelete(agentID); ok { + if server, ok := val.(*proxy.ProxyServer); ok { + server.Stop() } } - // Add the container to our list - containers = append(containers, ContainerInfo{ - ID: containerItem.Name, - AgentInfo: string(agentInfo), - ProxyPort: proxyPort, - CreatedAt: containerItem.Properties.LastModified, - LastActivity: lastActivity, - }) + // Close connection (this will also send FIN if not already sent) + agent.Conn.Close() + + // Remove from map + connectedAgents.Delete(key) + + // Clear selected agent if it was this one + if selectedAgent == agentID { + selectedAgent = "" + // Update prompt to default if app is available + if app != nil { + app.SetPrompt("proxyblob ยป ") + } + } + + log.Info().Str("agent_id", agentID).Str("listener_id", listenerID).Msg("Agent disconnected (listener stopped)") + } + return true + }) + + log.Info().Str("listener_id", listenerID).Msg("Listener stopped") + return nil +} + +// GenerateConnectionString creates a connection string for agents using the specified listener. +func GenerateConnectionString(listenerID string, expiry time.Duration) (string, error) { + val, ok := listeners.Load(listenerID) + if !ok { + return "", fmt.Errorf("listener '%s' not found", listenerID) + } + + state := val.(*ListenerState) + if !state.Running || state.Listener == nil { + return "", fmt.Errorf("listener '%s' is not running", listenerID) + } + + // The expiry parameter is currently ignored by the library's ConnectionString method. + // It uses the DefaultSASExpiry (24h) or the one set via WithSASExpiry during Listen. + connStr, err := state.Listener.ConnectionString() + if err != nil { + return "", err + } + + // Prepend the driver name so the agent knows which network to use for aznet.Dial + // Format: driver|connection_string + fullConnStr := state.Config.Driver + "|" + connStr + + // Base64 encode the connection string to match expected format + return base64.RawStdEncoding.EncodeToString([]byte(fullConnStr)), nil +} + +// AcceptAgentLoopForListener accepts incoming agent connections for a specific listener and stores them. +func AcceptAgentLoopForListener(ctx context.Context, listenerID string, listener net.Listener) { + for { + select { + case <-ctx.Done(): + return + default: + } + + conn, err := listener.Accept() + if err != nil { + if ctx.Err() != nil { + return + } + // Check if listener was stopped + val, ok := listeners.Load(listenerID) + if !ok { + return + } + state := val.(*ListenerState) + if !state.Running { + return + } + log.Error().Err(err).Str("listener_id", listenerID).Msg("Failed to accept connection") + continue + } + + // Generate a unique ID for this agent connection + agentID := uuid.New().String() + + // Read agent identity (user@host) with 5-second timeout + info := "unknown" + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err == nil && n > 0 { + info = string(buf[:n]) + } + conn.SetReadDeadline(time.Time{}) + + now := time.Now() + + // Store the connection + agent := &AgentConnection{ + ID: agentID, + Conn: conn, + ListenerID: listenerID, + Info: info, + LastSeen: now, + CreatedAt: now, + } + connectedAgents.Store(agentID, agent) + + log.Info().Str("agent_id", agentID).Str("info", info).Str("listener_id", listenerID).Msg("Agent connected") + + // Monitor connection in background + go monitorAgent(ctx, agentID, conn) + } +} + +// monitorAgent watches for agent disconnection via proxy server context. +// Note: We don't read from the connection directly as that interferes with the transport layer. +func monitorAgent(ctx context.Context, agentID string, conn net.Conn) { + // Wait for context cancellation - the proxy server will detect connection issues + <-ctx.Done() + + // Clean up on context cancellation + connectedAgents.Delete(agentID) + + // Stop proxy if running + if val, ok := runningProxies.LoadAndDelete(agentID); ok { + if server, ok := val.(*proxy.ProxyServer); ok { + server.Stop() } } - return containers, nil + conn.Close() + log.Info().Str("agent_id", agentID).Msg("Agent disconnected (context done)") } -// RenderAgentTable formats container information into a human-readable table. -// The table includes container ID, agent info, proxy port, and timing information. -func RenderAgentTable(containers []ContainerInfo) string { +// ListenerInfo tracks listener metadata for display. +type ListenerInfo struct { + ID string // listener ID/name + Status string // "running" or "stopped" + Protocol string // protocol scheme + Address string // listen address + StartedAt time.Time // when started (if running) +} + +// ListListeners returns information about all configured listeners. +func ListListeners() []ListenerInfo { + var listenerInfos []ListenerInfo + + // Get all configured listeners + for _, listenerConfig := range config.Listeners { + listenerID := listenerConfig.Name + info := ListenerInfo{ + ID: listenerID, + Status: "stopped", + Protocol: "", + Address: "", + } + + // Check if listener is running + if val, ok := listeners.Load(listenerID); ok { + state := val.(*ListenerState) + if state.Running { + info.Status = "running" + info.StartedAt = state.StartedAt + info.Protocol = state.Config.Driver + info.Address = state.ListenAddr + } + } + + // If not running, still show protocol from config + if info.Protocol == "" { + info.Protocol = listenerConfig.Driver + } + + listenerInfos = append(listenerInfos, info) + } + + return listenerInfos +} + +// ListAgents returns information about all connected agents. +func ListAgents() []AgentInfo { + var agents []AgentInfo + + connectedAgents.Range(func(key, value interface{}) bool { + agentID := key.(string) + agent := value.(*AgentConnection) + + var proxyPort string + if val, ok := runningProxies.Load(agentID); ok { + if server, ok := val.(*proxy.ProxyServer); ok && server.Listener != nil { + _, portStr, _ := net.SplitHostPort(server.Listener.Addr().String()) + proxyPort = portStr + } + } + + agents = append(agents, AgentInfo{ + AgentConnection: agent, + ProxyPort: proxyPort, + }) + return true + }) + + return agents +} + +// RenderListenerTable formats listener information into a human-readable table. +func RenderListenerTable(listeners []ListenerInfo, defaultID string) string { t := table.NewWriter() t.SetStyle(table.StyleRounded) - // Set up headers t.AppendHeader(table.Row{ - "Container ID", - "Agent info", - "Proxy port", - "First seen", - "Last seen", + "Name", + "Status", + "Protocol", + "Started At", }) - // Add rows for each container - for _, c := range containers { - // Add the container information as a row + // ANSI color codes + const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorGreen = "\033[32m" + ) + + for _, l := range listeners { + marker := "" + if l.ID == defaultID { + marker = " (default)" + } + + startedAt := "" + if l.Status == "running" && !l.StartedAt.IsZero() { + startedAt = l.StartedAt.Format("2006-01-02 15:04:05") + } + + // Color the status: green for running, red for stopped + statusColor := colorReset + switch l.Status { + case "running": + statusColor = colorGreen + case "stopped": + statusColor = colorRed + } + statusDisplay := statusColor + l.Status + colorReset + t.AppendRow(table.Row{ - c.ID, - c.AgentInfo, - c.ProxyPort, - c.CreatedAt.Format("2006-01-02 15:04:05"), - c.LastActivity.Format("2006-01-02 15:04:05"), + l.ID + marker, + statusDisplay, + l.Protocol, + startedAt, }) } - // Configure column options for better readability - t.SetColumnConfigs([]table.ColumnConfig{ - {Number: 1}, // Container ID - {Number: 2}, // Agent Info - {Number: 3}, // Proxy port - {Number: 4}, // Created At - {Number: 5}, // Last Activity - }) - return t.Render() } -// DeleteAgentContainer removes a container and its associated blobs. -// This terminates the connection with the remote agent. -func (sm *StorageManager) DeleteAgentContainer(ctx context.Context, containerID string) error { +// RenderAgentTable formats agent information into a human-readable table. +func RenderAgentTable(agents []AgentInfo) string { + t := table.NewWriter() + t.SetStyle(table.StyleRounded) - // Stop any running proxy for this container - if server, running := runningProxies.Load(containerID); running { - if proxyServer, ok := server.(*proxy.ProxyServer); ok { - proxyServer.Stop() - } - runningProxies.Delete(containerID) + t.AppendHeader(table.Row{ + "Agent ID", + "Info", + "Listener", + "Proxy Port", + "Connected At", + "Last Seen", + }) + + for _, a := range agents { + t.AppendRow(table.Row{ + a.ID, + a.Info, + a.ListenerID, + a.ProxyPort, + a.CreatedAt.Format("2006-01-02 15:04:05"), + formatRelativeTime(a.LastSeen), + }) } - // Create URL for the container we want to delete - containerURL := sm.ServiceURL.NewContainerURL(containerID) - - // Delete the container and all its contents - _, err := containerURL.Delete(ctx, azblob.ContainerAccessConditions{}) - if err != nil { - return fmt.Errorf("failed to delete container") - } - - return nil + return t.Render() } -// ValidateAgent checks if an agent container exists and is properly configured. -// Returns error if the container is missing. -func (sm *StorageManager) ValidateAgent(ctx context.Context, containerID string) error { - containerURL := sm.ServiceURL.NewContainerURL(containerID) - blobURL := containerURL.NewBlockBlobURL(InfoBlobName) - - // Try to get the info blob to verify this is a valid agent container - _, err := blobURL.GetProperties(ctx, azblob.BlobAccessConditions{}, azblob.ClientProvidedKeyOptions{}) - if err != nil { - if serr, ok := err.(azblob.StorageError); ok { - if serr.ServiceCode() == azblob.ServiceCodeContainerNotFound { - return fmt.Errorf("agent container %s does not exist", containerID) - } - } - return fmt.Errorf("invalid agent container %s: %v", containerID, err) +// formatRelativeTime returns a human-readable relative time string. +func formatRelativeTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds ago", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) } - - return nil -} - -// GetSelectedAgentInfo retrieves the metadata for the currently selected agent. -// Returns error if no agent is selected or the agent information is unavailable. -func (sm *StorageManager) GetSelectedAgentInfo(ctx context.Context) (string, error) { - if selectedAgent == "" { - return "", fmt.Errorf("no agent selected. Use 'agent use ' first") - } - - containerURL := sm.ServiceURL.NewContainerURL(selectedAgent) - blobURL := containerURL.NewBlockBlobURL(InfoBlobName) - - // Download the info blob - response, err := blobURL.Download(ctx, 0, azblob.CountToEnd, azblob.BlobAccessConditions{}, false, azblob.ClientProvidedKeyOptions{}) - if err != nil { - return "", fmt.Errorf("failed to get agent info: %v", err) - } - - // Read the agent info - agentInfo, err := io.ReadAll(response.Body(azblob.RetryReaderOptions{MaxRetryRequests: 3})) - if err != nil { - return "", fmt.Errorf("failed to read agent info: %v", err) - } - agentInfo = protocol.Xor(agentInfo, InfoKey) - - return string(agentInfo), nil } // AddCommands registers all CLI commands with the application. -// This includes commands for agent management, proxy control, and configuration. func AddCommands(app *grumble.App) { - // Command to create a new agent - app.AddCommand(&grumble.Command{ - Name: "create", - Aliases: []string{"new"}, - Help: "create a new agent container and generate its connection string", - Flags: func(f *grumble.Flags) { - f.Duration("d", "duration", 7*24*time.Hour, "duration for the SAS token. by default the token will be valid for 7 days") - }, - Run: func(c *grumble.Context) error { - expiry := c.Flags.Duration("duration") - containerID, connString, err := storageManager.CreateAgentContainer(expiry) - if err != nil { - log.Error().Err(err).Msg("Failed to create agent container") - return nil - } - log.Info().Str("container_id", containerID).Msg("Agent container created successfully") - log.Info().Str("connection_string", base64.RawStdEncoding.EncodeToString([]byte(connString))).Msg("Connection string generated") - return nil - }, - }) - // Command to list existing agents - app.AddCommand(&grumble.Command{ + // Parent command for listener management + listenerCmd := &grumble.Command{ + Name: "listener", + Help: "manage listeners", + } + + // Subcommand: listener list + listenerCmd.AddCommand(&grumble.Command{ Name: "list", Aliases: []string{"ls"}, - Help: "list all existing agent containers", + Help: "list all listeners", Run: func(c *grumble.Context) error { - ctx := context.Background() - - // Retrieve all containers - containers, err := storageManager.ListAgentContainers(ctx) - if err != nil { - log.Error().Err(err).Msg("Failed to list containers") + listenerInfos := ListListeners() + if len(listenerInfos) == 0 { + log.Info().Msg("No listeners configured") return nil } - // Display message if no containers found - if len(containers) == 0 { - log.Info().Msg("No agent containers found") - return nil - } - - // Display the container table - c.App.Println(RenderAgentTable(containers)) + c.App.Println(RenderListenerTable(listenerInfos, selectedListener)) return nil }, }) - // Command to delete an agent - app.AddCommand(&grumble.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Help: "delete an existing agent container", + + // Subcommand: listener start + listenerCmd.AddCommand(&grumble.Command{ + Name: "start", + Help: "start a listener", Args: func(a *grumble.Args) { - a.StringList("containers-id", "ID of the containers to delete") + a.StringList("listener-id", "ID of the listener to start") }, - Completer: CompleteAgents, + Completer: CompleteListeners, Run: func(c *grumble.Context) error { - containerIDs := c.Args.StringList("containers-id") - if len(containerIDs) == 0 { - containerIDs = append(containerIDs, selectedAgent) + listenerIDs := c.Args.StringList("listener-id") + listenerID := "" + if len(listenerIDs) != 0 { + listenerID = listenerIDs[0] + } else { + listenerID = selectedListener } - - for _, containerID := range containerIDs { - // Ask for confirmation before deletion - log.Info().Str("container_id", containerID).Msg("Are you sure you want to delete container? [y/N]") - var response string - fmt.Scanln(&response) - - if strings.ToLower(response) != "y" { - log.Info().Msg("Deletion cancelled") - return nil - } - - // Proceed with deletion - ctx := context.Background() - if err := storageManager.DeleteAgentContainer(ctx, containerID); err != nil { - log.Error().Err(err).Str("container_id", containerID).Msg("Failed to delete container") - return nil - } - - if selectedAgent == containerID { - selectedAgent = "" - c.App.SetPrompt("proxyblob ยป ") - } - - log.Info().Str("container_id", containerID).Msg("Container deleted successfully") + if listenerID == "" { + log.Error().Msg("No listener specified and no default listener selected. Use 'listener select ' first or specify a listener ID") + return nil + } + if err := StartListener(listenerID); err != nil { + log.Error().Err(err).Str("listener_id", listenerID).Msg("Failed to start listener") + return nil } return nil }, }) - // Command to select an agent + + // Subcommand: listener stop + listenerCmd.AddCommand(&grumble.Command{ + Name: "stop", + Help: "stop a listener", + Args: func(a *grumble.Args) { + a.StringList("listener-id", "ID of the listener to stop") + }, + Completer: CompleteListeners, + Run: func(c *grumble.Context) error { + listenerIDs := c.Args.StringList("listener-id") + listenerID := "" + if len(listenerIDs) != 0 { + listenerID = listenerIDs[0] + } else { + listenerID = selectedListener + } + if listenerID == "" { + log.Error().Msg("No listener specified and no default listener selected. Use 'listener select ' first or specify a listener ID") + return nil + } + if err := StopListener(listenerID); err != nil { + log.Error().Err(err).Str("listener_id", listenerID).Msg("Failed to stop listener") + return nil + } + return nil + }, + }) + + // Subcommand: listener select + listenerCmd.AddCommand(&grumble.Command{ + Name: "select", + Aliases: []string{"use"}, + Help: "select a default listener", + Args: func(a *grumble.Args) { + a.String("listener-id", "ID of the listener to select as default") + }, + Completer: CompleteListeners, + Run: func(c *grumble.Context) error { + listenerID := c.Args.String("listener-id") + + // Verify listener exists in config + found := false + for _, lc := range config.Listeners { + if lc.Name == listenerID { + found = true + break + } + } + + if !found { + log.Error().Str("listener_id", listenerID).Msg("Listener not found") + return nil + } + + selectedListener = listenerID + log.Info().Str("listener_id", listenerID).Msg("Listener selected as default") + return nil + }, + }) + + // Default action for listener command (list) + listenerCmd.Run = func(c *grumble.Context) error { + listenerInfos := ListListeners() + if len(listenerInfos) == 0 { + log.Info().Msg("No listeners configured") + return nil + } + + c.App.Println("Listeners:") + c.App.Println(RenderListenerTable(listenerInfos, selectedListener)) + return nil + } + + app.AddCommand(listenerCmd) + + // Command to create a new agent connection string app.AddCommand(&grumble.Command{ + Name: "new", + Help: "generate a new connection string for an agent", + Flags: func(f *grumble.Flags) { + f.Duration("d", "duration", 7*24*time.Hour, "duration for the SAS token (default 7 days)") + f.String("l", "listener", "", "listener ID to use (defaults to selected listener)") + }, + Run: func(c *grumble.Context) error { + listenerID := c.Flags.String("listener") + if listenerID == "" { + listenerID = selectedListener + } + + if listenerID == "" { + log.Error().Msg("No listener specified and no default listener selected. Use 'listener start ' to start a listener (it becomes the default), or use 'listener select ' to select a default, or use --listener flag to specify a listener") + return nil + } + + // Check if listener is running + val, ok := listeners.Load(listenerID) + if !ok { + log.Error().Str("listener_id", listenerID).Msg("Listener not found or not started") + return nil + } + + state := val.(*ListenerState) + if !state.Running { + log.Error().Str("listener_id", listenerID).Msg("Listener is not running") + return nil + } + + expiry := c.Flags.Duration("duration") + connString, err := GenerateConnectionString(listenerID, expiry) + if err != nil { + log.Error().Err(err).Str("listener_id", listenerID).Msg("Failed to generate connection string") + return nil + } + + log.Info().Str("listener_id", listenerID).Str("connection_string", connString).Msg("Connection string generated") + return nil + }, + }) + + // Parent command for agent management + agentCmd := &grumble.Command{ + Name: "agent", + Help: "manage agents", + } + + // Subcommand: agent list + agentCmd.AddCommand(&grumble.Command{ + Name: "list", + Aliases: []string{"ls"}, + Help: "list all connected agents", + Run: func(c *grumble.Context) error { + agents := ListAgents() + if len(agents) == 0 { + log.Info().Msg("No agents connected") + return nil + } + + c.App.Println("Agents:") + c.App.Println(RenderAgentTable(agents)) + return nil + }, + }) + + // Subcommand: agent select + agentCmd.AddCommand(&grumble.Command{ Name: "select", Aliases: []string{"use"}, Help: "select an agent for subsequent commands", Args: func(a *grumble.Args) { - a.String("container-id", "ID of the container to select") + a.String("agent-id", "ID of the agent to select") }, Completer: CompleteAgents, Run: func(c *grumble.Context) error { - ctx := context.Background() - containerID := c.Args.String("container-id") + agentID := c.Args.String("agent-id") - // Validate the agent exists - if err := storageManager.ValidateAgent(ctx, containerID); err != nil { - log.Error().Err(err).Msg("Failed to validate agent") + // Verify agent exists + if _, ok := connectedAgents.Load(agentID); !ok { + log.Error().Str("agent_id", agentID).Msg("Agent not found") return nil } - // Store the selected agent - selectedAgent = containerID - - // Get and display agent info - agentInfo, err := storageManager.GetSelectedAgentInfo(ctx) - if err != nil { - log.Error().Err(err).Msg("Failed to get agent info") - return nil - } - if agentInfo == "" { - agentInfo = "unknown@host" - } - - log.Info().Str("agent", agentInfo).Msg("Agent selected") - c.App.SetPrompt(agentInfo + " ยป ") + selectedAgent = agentID + log.Info().Str("agent_id", agentID).Msg("Agent selected") + c.App.SetPrompt(agentID[:8] + " ยป ") return nil }, }) - // Command to start the proxy server over the current selected agent - app.AddCommand(&grumble.Command{ + + // Subcommand: agent start + agentCmd.AddCommand(&grumble.Command{ Name: "start", - Aliases: []string{"proxy"}, - Help: "start SOCKS proxy server", + Help: "start SOCKS proxy for the selected agent", Flags: func(f *grumble.Flags) { f.String("l", "listen", "127.0.0.1:1080", "listen address for SOCKS server") }, Run: func(c *grumble.Context) error { if selectedAgent == "" { - log.Warn().Msg("No agent selected. Use 'select ' first") + log.Warn().Msg("No agent selected. Use 'agent select ' first") return nil } + // Check if proxy already running if _, exists := runningProxies.Load(selectedAgent); exists { log.Warn().Msg("Proxy already running for this agent") return nil } - // Verify the container still exists before starting a proxy - ctx := context.Background() - - // Check if the container exists - if err := storageManager.ValidateAgent(ctx, selectedAgent); err != nil { - log.Error().Err(err).Msg("Cannot start proxy") + // Get the agent connection + val, ok := connectedAgents.Load(selectedAgent) + if !ok { + log.Error().Msg("Selected agent no longer connected") + selectedAgent = "" + c.App.SetPrompt("proxyblob ยป ") return nil } - containerURL := storageManager.ServiceURL.NewContainerURL(selectedAgent) - transport := transport.NewBlobTransport( - containerURL.NewBlockBlobURL(ResponseBlobName), - containerURL.NewBlockBlobURL(RequestBlobName), - ) + agent := val.(*AgentConnection) + ctx := context.Background() - server := proxy.NewProxyServer(ctx, transport) - listenAddr := c.Flags.String("listen") + // Create a proxy server for this agent (direct connection, no transport wrapper) + proxyServer := proxy.NewProxyServer(ctx, agent.Conn) - runningProxies.Store(selectedAgent, server) - server.Start(listenAddr) - - // Log the port info for user feedback - if server.Listener != nil { - _, portStr, _ := net.SplitHostPort(server.Listener.Addr().String()) - // Get agent info for notification - agentInfo, err := storageManager.GetSelectedAgentInfo(context.Background()) - if err != nil || agentInfo == "" { - agentInfo = selectedAgent // Fallback to container ID if we can't get agent info - } - - log.Info().Str("agent", agentInfo).Str("port", portStr).Msg("Proxy started successfully") + // Set callback to update LastSeen on every received packet + proxyServer.OnReceive = func() { + agent.LastSeen = time.Now() } + listenAddr := c.Flags.String("listen") + host, port, err := net.SplitHostPort(listenAddr) + if err != nil { + log.Error().Err(err).Str("listen", listenAddr).Msg("Failed to parse listen address") + return nil + } + oldPort := port + + portInt, _ := strconv.Atoi(port) + for { + portAvailable := true + runningProxies.Range(func(key, value interface{}) bool { + server := value.(*proxy.ProxyServer) + if server.Listener != nil { + _, serverPort, _ := net.SplitHostPort(server.Listener.Addr().String()) + if serverPort == port { + portAvailable = false + return false + } + } + return true + }) + if portAvailable { + break + } + portInt++ + port = strconv.Itoa(portInt) + } + + if oldPort != port { + log.Warn().Str("used_port", oldPort).Str("selected_port", port).Msg("Proxy already running on this port") + } + + listenAddr = fmt.Sprintf("%s:%s", host, port) + proxyServer.Start(listenAddr) + + if proxyServer.Listener == nil { + log.Error().Str("addr", listenAddr).Msg("Failed to start proxy") + return nil + } + + runningProxies.Store(selectedAgent, proxyServer) + + _, portStr, _ := net.SplitHostPort(proxyServer.Listener.Addr().String()) + log.Info().Str("agent_id", selectedAgent).Str("port", portStr).Msg("Proxy started") + return nil }, }) - // Command to stop the proxy server running the current selected agent - app.AddCommand(&grumble.Command{ + + // Subcommand: agent stop + agentCmd.AddCommand(&grumble.Command{ Name: "stop", - Help: "stop running proxy for the selected agent", + Help: "stop the proxy for the selected agent", Run: func(c *grumble.Context) error { if selectedAgent == "" { - log.Warn().Msg("No agent selected. Use 'select ' first") + log.Warn().Msg("No agent selected. Use 'agent select ' first") return nil } - // Retrieve and remove the value from the map in one atomic operation - value, exists := runningProxies.LoadAndDelete(selectedAgent) + // Get and remove the proxy + val, exists := runningProxies.LoadAndDelete(selectedAgent) if !exists { log.Warn().Msg("No proxy running for this agent") return nil } - // Try to stop the proxy gracefully - server, _ := value.(*proxy.ProxyServer) + // Stop the proxy + server := val.(*proxy.ProxyServer) server.Stop() - // Get agent info for notification - agentInfo, err := storageManager.GetSelectedAgentInfo(context.Background()) - if err != nil || agentInfo == "" { - agentInfo = selectedAgent // Fallback to container ID if we can't get agent info - } - - log.Info().Str("agent", agentInfo).Msg("Proxy stopped") + log.Info().Str("agent_id", selectedAgent).Msg("Proxy stopped") return nil }, }) + + // Subcommand: agent remove + agentCmd.AddCommand(&grumble.Command{ + Name: "remove", + Aliases: []string{"rm"}, + Help: "remove an agent (disconnect and stop proxy)", + Args: func(a *grumble.Args) { + a.String("agent-id", "ID of the agent to disconnect", grumble.Default(selectedAgent)) + }, + Completer: CompleteAgents, + Run: func(c *grumble.Context) error { + agentID := c.Args.String("agent-id") + if agentID == "" { + agentID = selectedAgent + } + + // Get the agent + val, ok := connectedAgents.LoadAndDelete(agentID) + if !ok { + log.Error().Str("agent_id", agentID).Msg("Agent not found") + return nil + } + + agent := val.(*AgentConnection) + + // Stop proxy if running + if val, ok := runningProxies.LoadAndDelete(agentID); ok { + if server, ok := val.(*proxy.ProxyServer); ok { + server.Stop() + } + } + + // Close the connection + agent.Conn.Close() + + if selectedAgent == agentID { + selectedAgent = "" + c.App.SetPrompt("proxyblob ยป ") + } + + log.Info().Str("agent_id", agentID).Msg("Agent disconnected") + + return nil + }, + }) + + // Default action for agent command (list) + agentCmd.Run = func(c *grumble.Context) error { + agents := ListAgents() + if len(agents) == 0 { + log.Info().Msg("No agents connected") + return nil + } + + c.App.Println(RenderAgentTable(agents)) + return nil + } + + app.AddCommand(agentCmd) } // CompleteAgents provides tab completion for agent IDs. -// Returns a list of available agent container IDs. func CompleteAgents(_ string, _ []string) []string { - containers, err := storageManager.ListAgentContainers(context.Background()) - if err != nil { - return []string{} // Return empty slice on error - } - var completions []string - for _, container := range containers { - completions = append(completions, container.ID) + connectedAgents.Range(func(key, _ interface{}) bool { + completions = append(completions, key.(string)) + return true + }) + return completions +} + +// CompleteListeners provides tab completion for listener IDs. +func CompleteListeners(_ string, _ []string) []string { + var completions []string + for _, listenerConfig := range config.Listeners { + completions = append(completions, listenerConfig.Name) } return completions } -// ----------------------------------------------------------------------------- -// Main Application Entry -// ----------------------------------------------------------------------------- - // main is the entry point for the application. -// It sets up the CLI, configuration, and command handlers. func main() { // Set up logging configureLogging() // Configure and create the CLI app - app := setupCLI() + app = setupCLI() // Add all command handlers AddCommands(app) @@ -719,26 +1051,23 @@ func main() { // configureLogging sets up zerolog with appropriate formatting and level. func configureLogging() { - // Configure zerolog with a pretty console writer for interactive use log.Logger = log.Output(zerolog.ConsoleWriter{ Out: os.Stdout, TimeFormat: "15:04:05", }) - // Set reasonable default log level zerolog.SetGlobalLevel(zerolog.InfoLevel) } // setupCLI initializes the command-line interface with basic configuration. -// Returns a configured grumble App instance. func setupCLI() *grumble.App { // Determine history file location var histFile string home, err := os.UserHomeDir() if err != nil { - histFile = ".proxyblob" // current working directory + histFile = ".proxyblob" } else { - histFile = filepath.Join(home, ".proxyblob") // home directory + histFile = filepath.Join(home, ".proxyblob") } // Create and configure the CLI app @@ -750,30 +1079,23 @@ func setupCLI() *grumble.App { }, }) - // Set up our ASCII art banner + // Set up banner app.SetPrintASCIILogo(func(a *grumble.App) { fmt.Print(banner) }) // Initialize configuration when the app starts app.OnInit(func(a *grumble.App, flags grumble.FlagMap) error { - // Load configuration from file + // Load configuration var err error config, err = LoadConfig(flags.String("config")) if err != nil { return fmt.Errorf("failed to load configuration: %v", err) } - // Validate the configuration - if err := config.Validate(); err != nil { - return fmt.Errorf("invalid configuration: %v", err) - } - - // Initialize the storage manager - storageManager, err = NewStorageManager(config) - if err != nil { - return fmt.Errorf("failed to initialize storage manager: %v", err) - } + // Note: Listeners are not auto-started. User must start them explicitly. + // When a listener is started, it automatically becomes the default. + log.Info().Int("listener_count", len(config.Listeners)).Msg("Configuration loaded. Use 'listener start ' to start a listener (it will become the default).") return nil }) diff --git a/docs/proxyblob-v2.png b/docs/proxyblob-v2.png new file mode 100644 index 0000000..da53896 Binary files /dev/null and b/docs/proxyblob-v2.png differ diff --git a/example_config.json b/example_config.json index 0a3c6e3..47c2742 100644 --- a/example_config.json +++ b/example_config.json @@ -1,4 +1,18 @@ { - "storage_account_name": "storage-name", - "storage_account_key": "storage-access-key" -} \ No newline at end of file + "listeners": [ + { + "name": "blob-listener", + "driver": "azblob", + "address": "https://proxyblob.blob.core.windows.net", + "storage_account": "proxyblob", + "storage_account_key": "your_account_key" + }, + { + "name": "queue-listener", + "driver": "azqueue", + "address": "http://127.0.0.1:10001", + "storage_account": "devstoreaccount1", + "storage_account_key": "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==" + } + ] +} diff --git a/go.mod b/go.mod index c958acc..a6408f7 100644 --- a/go.mod +++ b/go.mod @@ -1,40 +1,42 @@ module proxyblob -go 1.23.4 +go 1.25.0 require ( - github.com/desertbit/grumble v1.1.3 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.4.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azqueue v1.0.1 // indirect + github.com/desertbit/grumble v1.2.0 github.com/google/uuid v1.6.0 - golang.org/x/crypto v0.33.0 ) require ( - github.com/Azure/azure-pipeline-go v0.2.3 // indirect - github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/go-openapi/errors v0.22.0 // indirect - github.com/go-openapi/strfmt v0.23.0 // indirect - github.com/golang-jwt/jwt/v4 v4.5.1 // indirect - github.com/mattn/go-ieproxy v0.0.12 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/oklog/ulid v1.3.1 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - go.mongodb.org/mongo-driver v1.17.3 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/text v0.22.0 // indirect -) - -require ( - github.com/Azure/azure-storage-blob-go v0.15.0 + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/desertbit/closer/v3 v3.7.5 // indirect github.com/desertbit/columnize v2.1.0+incompatible // indirect github.com/desertbit/go-shlex v0.1.1 // indirect github.com/desertbit/readline v1.5.1 // indirect github.com/fatih/color v1.18.0 // indirect - github.com/jedib0t/go-pretty v4.3.0+incompatible + github.com/flynn/noise v1.1.0 // indirect + github.com/go-openapi/errors v0.22.3 // indirect + github.com/go-openapi/strfmt v0.24.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/rs/zerolog v1.33.0 - golang.org/x/sys v0.30.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/oklog/ulid v1.3.1 // indirect + go.mongodb.org/mongo-driver v1.17.4 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect +) + +require ( + github.com/atsika/aznet v0.0.0-20260216144450-1395eae946cd + github.com/jedib0t/go-pretty v4.3.0+incompatible + github.com/rs/zerolog v1.34.0 ) diff --git a/go.sum b/go.sum index c2c81bc..f6a20d8 100644 --- a/go.sum +++ b/go.sum @@ -1,190 +1,205 @@ -github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U= -github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k= -github.com/Azure/azure-storage-blob-go v0.15.0 h1:rXtgp8tN1p29GvpGgfJetavIG0V7OgcSXPpwp3tx6qk= -github.com/Azure/azure-storage-blob-go v0.15.0/go.mod h1:vbjsVbX0dlxnRc4FFMPsS9BsJWPcne7GB7onqlPvz58= -github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= -github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= -github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= -github.com/Azure/go-autorest/autorest/adal v0.9.24 h1:BHZfgGsGwdkHDyZdtQRQk1WeUdW0m2WPAwuHZwUi5i4= -github.com/Azure/go-autorest/autorest/adal v0.9.24/go.mod h1:7T1+g0PYFmACYW5LlG2fcoPiPlFHjClyRGL7dRlP5c8= -github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= -github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= -github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= -github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= -github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= -github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= -github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/AlecAivazis/survey/v2 v2.0.5/go.mod h1:WYBhg6f0y/fNYUuesWQc0PKbJcEliGcYHB9sNT3Bg74= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.4.1 h1:j0hhYS006eJ54vusoap0f2NVZ1YY3QnaAEnLM68f0SQ= +github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.4.1/go.mod h1:AdtInaXmK8eYmbjezRWgLz+Qs46nc9Up9GWGwteWNfw= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= +github.com/Azure/azure-sdk-for-go/sdk/storage/azqueue v1.0.1 h1:qvrrnQ2mIjwY7IVlQuNB0ma43Nr74+9ZTZJ60KlmlV4= +github.com/Azure/azure-sdk-for-go/sdk/storage/azqueue v1.0.1/go.mod h1:FkF/Az07vR3S4sBdjCuisznWfFWOD8u6Ibm/g/oyDAk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= github.com/Netflix/go-expect v0.0.0-20190729225929-0e00d9168667/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atsika/aznet v0.0.0-20260216144450-1395eae946cd h1:tvxn6W9ky7ROcX5M3SbIHCDdypnQRPiBC/3K/+3+sBo= +github.com/atsika/aznet v0.0.0-20260216144450-1395eae946cd/go.mod h1:0+F6U1Bi8CrghJ51eAY1J7B9pCG5FCJc930Dm1Jqh1I= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= 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/desertbit/closer/v3 v3.1.2/go.mod h1:AAC4KRd8DC40nwvV967J/kDFhujMEiuwIKQfN0IDxXw= +github.com/desertbit/closer/v3 v3.1.3/go.mod h1:AAC4KRd8DC40nwvV967J/kDFhujMEiuwIKQfN0IDxXw= github.com/desertbit/closer/v3 v3.7.5 h1:tJ3BXDeflcWjGSacIQFiFOZf3ep7kit9HMxM87qXVLc= github.com/desertbit/closer/v3 v3.7.5/go.mod h1:wxbB5mDxqhQC8CjI8ApBhj9aHHSLjdok5WFkJj4Bq7M= github.com/desertbit/columnize v2.1.0+incompatible h1:h55rYmdrWoTj7w9aAnCkxzM3C2Eb8zuFa2W41t0o5j0= github.com/desertbit/columnize v2.1.0+incompatible/go.mod h1:5kPrzQwKbQ8E5D28nvTVPqIBJyj+8jvJzwt6HXZvXgI= github.com/desertbit/go-shlex v0.1.1 h1:c65HnbgX1QyC6kPL1dMzUpZ4puNUE6ai/eVucWNLNsk= github.com/desertbit/go-shlex v0.1.1/go.mod h1:Qbb+mJNud5AypgHZ81EL8syOGaWlwvAOTqS7XmWI4pQ= -github.com/desertbit/grumble v1.1.3 h1:gbdgVGWsHmNraJ7Gn6Q4TiUEIHU/UHfbc1KUSbBlgYU= -github.com/desertbit/grumble v1.1.3/go.mod h1:r7j3ShNy5EmOsegRD2DzTutIaGiLiA3M5yBTXXeLwcs= +github.com/desertbit/grumble v1.2.0 h1:STmiLglcNWLjh1N9Qm+l1NIUjxNk3FH9sS7yPSlUI3I= +github.com/desertbit/grumble v1.2.0/go.mod h1:XfdVDEGzjcFO7GFBq5xrdm3dhZTId0XC4RMG+KEsB68= github.com/desertbit/readline v1.5.1 h1:/wOIZkWYl1s+IvJm/5bOknfUgs6MhS9svRNZpFM53Os= github.com/desertbit/readline v1.5.1/go.mod h1:pHQgTsCFs9Cpfh5mlSUFi9Xa5kkL4d8L1Jo4UVWzPw0= -github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= -github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= -github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= -github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= -github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= -github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= +github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= +github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= +github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs= +github.com/go-openapi/errors v0.22.3/go.mod h1:+WvbaBBULWCOna//9B9TbLNGSFOfF8lY9dw4hGiEiKQ= +github.com/go-openapi/strfmt v0.24.0 h1:dDsopqbI3wrrlIzeXRbqMihRNnjzGC+ez4NQaAAJLuc= +github.com/go-openapi/strfmt v0.24.0/go.mod h1:Lnn1Bk9rZjXxU9VMADbEEOo7D7CDyKGLsSKekhFr7s4= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= -github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= github.com/hinshun/vt10x v0.0.0-20180809195222-d55458df857c/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 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/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= 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/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E= -github.com/mattn/go-ieproxy v0.0.12 h1:OZkUFJC3ESNZPQ+6LzC3VJIFSnreeFLQyqvBWtvfL2M= -github.com/mattn/go-ieproxy v0.0.12/go.mod h1:Vn+N61199DAnVeTgaF8eoB9PvLO8P3OBnG95ENh7B7c= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 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.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/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/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= -go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -golang.org/x/crypto v0.0.0-20190123085648-057139ce5d2b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= +go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180606202747-9527bec2660b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/AlecAivazis/survey.v1 v1.8.5/go.mod h1:iBNOmqKz/NUbZx3bA+4hAGLRC7fSK7tgtVDT4tB22XA= +gopkg.in/AlecAivazis/survey.v1 v1.8.8/go.mod h1:CaHjv79TCgAvXMSFJSVgonHXYWxnhzI3eoHtnX5UgUo= 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= diff --git a/pkg/protocol/base.go b/pkg/protocol/base.go index 55ae858..148bd3b 100644 --- a/pkg/protocol/base.go +++ b/pkg/protocol/base.go @@ -2,11 +2,11 @@ package protocol import ( "context" + "io" + "net" "sync" "time" - "proxyblob/pkg/transport" - "github.com/google/uuid" ) @@ -38,8 +38,8 @@ type PacketHandler interface { // BaseHandler implements common protocol functionality for proxy and agent. // It provides connection management, packet routing, and error handling. type BaseHandler struct { - // transport handles underlying packet transmission - transport transport.Transport + // conn handles underlying packet transmission (direct net.Conn) + conn net.Conn // Connections maps UUIDs to active Connection objects Connections sync.Map @@ -50,70 +50,95 @@ type BaseHandler struct { // Cancel terminates handler context Cancel context.CancelFunc + // OnReceive is called on every successful packet read (optional) + OnReceive func() + // PacketHandler routes packets to specific handlers PacketHandler } -// NewBaseHandler creates a handler with specified context and transport. +// NewBaseHandler creates a handler with specified context and connection. // Uses background context if parent context is nil. -func NewBaseHandler(parentCtx context.Context, transport transport.Transport) *BaseHandler { +func NewBaseHandler(parentCtx context.Context, conn net.Conn) *BaseHandler { if parentCtx == nil { parentCtx = context.Background() } ctx, cancel := context.WithCancel(parentCtx) return &BaseHandler{ - transport: transport, - Ctx: ctx, - Cancel: cancel, + conn: conn, + Ctx: ctx, + Cancel: cancel, } } -// ReceiveLoop processes incoming packets until context cancellation. -// Implements exponential backoff for consecutive errors. +// ReceiveLoop processes incoming packets until EOF or context cancellation. +// Uses exponential backoff on transient errors but never exits silently. func (h *BaseHandler) ReceiveLoop() { consecutiveErrors := 0 - maxConsecutiveErrors := 5 + const maxBackoff = 5 * time.Second + + // NOTE: aznet already handles message framing and returns complete messages + // We don't need stream buffering - each Read() returns ONE complete message + + // Allocate buffer once and reuse it (16MB is the max frame size in aznet) + buffer := make([]byte, 16*1024*1024) for { select { case <-h.Ctx.Done(): return default: - data, errCode := h.transport.Receive(h.Ctx) - if errCode != ErrNone { - if h.transport.IsClosed(errCode) { - h.Stop() - return - } + } - if h.Ctx.Err() == nil && errCode != ErrTransportError { - consecutiveErrors++ - if consecutiveErrors == maxConsecutiveErrors { - return // Too many errors, just exit - } - time.Sleep(time.Duration(consecutiveErrors*50) * time.Millisecond) - } + // Read directly from connection (aznet handles framing) + n, err := h.conn.Read(buffer) + if err != nil { + if err == io.EOF { + h.Stop() + return + } + + if h.Ctx.Err() != nil { + return + } + + // Transient error: exponential backoff (100ms, 200ms, 400ms, ... capped at 5s) + consecutiveErrors++ + backoff := time.Duration(100< maxBackoff { + backoff = maxBackoff + } + // Interruptible sleep + select { + case <-h.Ctx.Done(): + return + case <-time.After(backoff): + } + continue + } + + consecutiveErrors = 0 + if h.OnReceive != nil { + h.OnReceive() + } + data := buffer[:n] + + if len(data) == 0 { + continue + } + + packet := Decode(data) + if packet == nil { + continue + } + + errCode := h.handlePacket(packet) + if errCode != ErrNone { + if h.Ctx.Err() != nil { continue } - - consecutiveErrors = 0 - - if len(data) == 0 { - continue - } - - packet := Decode(data) - if packet == nil { - continue - } - - errCode = h.handlePacket(packet) - if errCode != ErrNone { - if h.Ctx.Err() != nil && errCode == ErrConnectionClosed { - continue - } - h.SendClose(packet.ConnectionID, errCode) - } + // Async close dispatch to avoid blocking ReceiveLoop on aznet writes + go h.SendClose(packet.ConnectionID, errCode) } } } @@ -135,73 +160,25 @@ func (h *BaseHandler) handlePacket(packet *Packet) byte { } } -// SendNewConnection initiates key exchange for new connection. +// SendNewConnection initiates a new connection. // Returns error code indicating success or specific failure. func (h *BaseHandler) SendNewConnection(connectionID uuid.UUID) byte { - privateKey, publicKey := GenerateKeyPair() - nonce := GenerateNonce() - - connObj, exists := h.Connections.Load(connectionID) - if !exists { - return ErrConnectionNotFound - } - conn := connObj.(*Connection) - - // Store nonce and private key for key derivation during ACK - tempData := make([]byte, len(nonce)+len(privateKey)) - copy(tempData[:len(nonce)], nonce) - copy(tempData[len(nonce):], privateKey) - conn.SecretKey = tempData - - // Send nonce and public key to peer - data := make([]byte, len(nonce)+len(publicKey)) - copy(data[:len(nonce)], nonce) - copy(data[len(nonce):], publicKey) - - return h.sendPacket(CmdNew, connectionID, data) + return h.sendPacket(CmdNew, connectionID, nil) } -// SendConnAck completes key exchange by deriving shared key. +// SendConnAck acknowledges connection. // Returns error code indicating success or specific failure. func (h *BaseHandler) SendConnAck(connectionID uuid.UUID) byte { - connObj, exists := h.Connections.Load(connectionID) - if !exists { - return ErrConnectionNotFound - } - conn := connObj.(*Connection) - - privateKey, publicKey := GenerateKeyPair() - - // The first 24 bytes of SecretKey should be the nonce, - // and the server's public key should be in the data field from OnNew - serverData := conn.SecretKey - nonce := serverData[:24] - serverPublicKey := serverData[24:] - - symmetricKey, errCode := DeriveKey(privateKey, serverPublicKey, nonce) - if errCode != ErrNone { - return errCode - } - - conn.SecretKey = symmetricKey - - // Send public key in CmdAck - return h.sendPacket(CmdAck, connectionID, publicKey) + return h.sendPacket(CmdAck, connectionID, nil) } +// SendData sends data. +// Returns error code indicating success or specific failure. func (h *BaseHandler) SendData(connectionID uuid.UUID, data []byte) byte { - // Get connection - connObj, exists := h.Connections.Load(connectionID) - if !exists { + // Verify connection exists + if _, exists := h.Connections.Load(connectionID); !exists { return ErrConnectionNotFound } - conn := connObj.(*Connection) - - encrypted, errCode := Encrypt(conn.SecretKey, data) - if errCode != ErrNone { - return errCode - } - data = encrypted return h.sendPacket(CmdData, connectionID, data) } @@ -215,6 +192,7 @@ func (h *BaseHandler) SendClose(connectionID uuid.UUID, errCode byte) byte { conn := connObj.(*Connection) conn.Close() + h.Connections.Delete(connectionID) return h.sendPacket(CmdClose, connectionID, []byte{errCode}) } @@ -235,13 +213,12 @@ func (h *BaseHandler) sendPacket(cmd byte, connectionID uuid.UUID, data []byte) return ErrInvalidPacket } - errCode := h.transport.Send(h.Ctx, encoded) - if errCode != ErrNone { - // Check if this is a transport closure - if h.transport.IsClosed(errCode) { + _, err := h.conn.Write(encoded) + if err != nil { + if err == io.EOF { return ErrTransportClosed } - return ErrPacketSendFailed + return ErrTransportError } return ErrNone @@ -250,15 +227,8 @@ func (h *BaseHandler) sendPacket(cmd byte, connectionID uuid.UUID, data []byte) func (h *BaseHandler) CloseAllConnections() { h.Connections.Range(func(key, value interface{}) bool { conn := value.(*Connection) - - // Only notify if not already closed - select { - case <-conn.Closed: - // Already closed - default: - conn.Close() - } - + conn.Close() + h.Connections.Delete(key) return true }) } diff --git a/pkg/protocol/connection.go b/pkg/protocol/connection.go index b55dc6e..98c65f4 100644 --- a/pkg/protocol/connection.go +++ b/pkg/protocol/connection.go @@ -2,51 +2,44 @@ package protocol import ( "net" + "sync" "time" "github.com/google/uuid" ) -// ConnectionState tracks the lifecycle of a proxy connection -type ConnectionState int - -const ( - // StateNew indicates a pending connection awaiting establishment - StateNew ConnectionState = iota - - // StateConnected indicates an active connection with data flow - StateConnected - - // StateClosed indicates a terminated connection - StateClosed -) - // Connection manages a proxy connection between client and target. // It is safe for concurrent use by multiple goroutines. +// +// Connection state is determined by: +// - ProtocolConn == nil: connection is pending acknowledgment +// - ProtocolConn != nil && Closed not signaled: connection is active +// - Closed signaled: connection is terminated type Connection struct { // ID uniquely identifies the connection ID uuid.UUID - // State indicates current connection lifecycle phase - State ConnectionState - - // Conn holds the network connection (optional) + // Conn holds the network connection (could be real or virtual ProtocolConn) Conn net.Conn - // ReadBuffer receives data from the remote endpoint - ReadBuffer chan []byte + // ProtocolConn is the virtual connection for protocol-based data transfer + ProtocolConn *ProtocolConn // Closed signals connection termination Closed chan struct{} + // deliverCh is the dispatch buffer between ReceiveLoop and the delivery goroutine. + // ReceiveLoop writes here non-blocking; the delivery goroutine drains to readBuffer. + deliverCh chan []byte + + // closeOnce guards Close() against double-close panics + closeOnce sync.Once + // CreatedAt records connection creation time CreatedAt time.Time // LastActivity tracks most recent data transfer LastActivity time.Time - - // SecretKey holds encryption key for secure communication - SecretKey []byte } // NewConnection creates a connection with specified ID. @@ -54,36 +47,55 @@ type Connection struct { func NewConnection(id uuid.UUID) *Connection { return &Connection{ ID: id, - State: StateNew, - ReadBuffer: make(chan []byte), Closed: make(chan struct{}), + deliverCh: make(chan []byte, 256), CreatedAt: time.Now(), LastActivity: time.Now(), } } // Close terminates the connection and its resources. -// Safe to call multiple times. Returns ErrNone on success. +// Safe to call multiple times (guarded by closeOnce). Returns ErrNone. func (c *Connection) Close() byte { - if c.State == StateClosed { - return ErrNone - } - - c.State = StateClosed - - select { - case <-c.Closed: - // Already closed - default: + c.closeOnce.Do(func() { close(c.Closed) - } - - if c.Conn != nil { - err := c.Conn.Close() - if err != nil { - return ErrConnectionClosed + if c.ProtocolConn != nil { + c.ProtocolConn.Shutdown() } - } - + if c.Conn != nil { + c.Conn.Close() + } + }) return ErrNone } + +// Deliver sends data to the connection's delivery goroutine without blocking. +// Returns true if data was accepted, false if the buffer is full or connection is closed. +func (c *Connection) Deliver(data []byte) bool { + select { + case c.deliverCh <- data: + return true + case <-c.Closed: + return false + default: + return false // dispatch buffer full + } +} + +// StartDelivery launches a goroutine that drains deliverCh into ProtocolConn.DeliverData. +// Must be called after ProtocolConn is set. +func (c *Connection) StartDelivery() { + go func() { + for { + select { + case data, ok := <-c.deliverCh: + if !ok { + return + } + c.ProtocolConn.DeliverData(data) + case <-c.Closed: + return + } + } + }() +} diff --git a/pkg/protocol/crypto.go b/pkg/protocol/crypto.go deleted file mode 100644 index f329c66..0000000 --- a/pkg/protocol/crypto.go +++ /dev/null @@ -1,98 +0,0 @@ -package protocol - -import ( - "crypto/rand" - "io" - - "golang.org/x/crypto/chacha20poly1305" - "golang.org/x/crypto/curve25519" - "golang.org/x/crypto/hkdf" - "golang.org/x/crypto/sha3" -) - -// GenerateKeyPair creates a new X25519 key pair for key exchange. -// Returns a properly clamped private key and its corresponding public key. -func GenerateKeyPair() (privateKey, publicKey []byte) { - privateKey = make([]byte, curve25519.ScalarSize) - io.ReadFull(rand.Reader, privateKey) - - // Clamp private key according to X25519 spec - privateKey[0] &= 248 - privateKey[31] &= 127 - privateKey[31] |= 64 - - publicKey, _ = curve25519.X25519(privateKey, curve25519.Basepoint) - return privateKey, publicKey -} - -// GenerateNonce creates a random nonce for ChaCha20-Poly1305. -// Returns a cryptographically secure random nonce. -func GenerateNonce() []byte { - nonce := make([]byte, chacha20poly1305.NonceSizeX) - io.ReadFull(rand.Reader, nonce) - return nonce -} - -// DeriveKey performs X25519 key exchange and HKDF key derivation. -// Returns a symmetric key and status code. Key is nil on error. -func DeriveKey(privateKey, peerPublicKey, nonce []byte) ([]byte, byte) { - // Derive shared secret using X25519 - sharedSecret, err := curve25519.X25519(privateKey, peerPublicKey) - if err != nil { - return nil, ErrInvalidCrypto - } - - // Derive symmetric key using HKDF-SHA3 - kdf := hkdf.New(sha3.New256, sharedSecret, nonce, nil) - symmetricKey := make([]byte, chacha20poly1305.KeySize) - if _, err := io.ReadFull(kdf, symmetricKey); err != nil { - return nil, ErrInvalidCrypto - } - - return symmetricKey, ErrNone -} - -// Encrypt performs authenticated encryption using ChaCha20-Poly1305. -// Returns (nonce || ciphertext || tag) or nil on error. -func Encrypt(key, plaintext []byte) ([]byte, byte) { - aead, err := chacha20poly1305.NewX(key) - if err != nil { - return nil, ErrInvalidCrypto - } - - nonce := GenerateNonce() - ciphertext := aead.Seal(nonce, nonce, plaintext, nil) - return ciphertext, ErrNone -} - -// Decrypt performs authenticated decryption using ChaCha20-Poly1305. -// Returns decrypted plaintext or nil if authentication fails. -func Decrypt(key, ciphertext []byte) ([]byte, byte) { - aead, err := chacha20poly1305.NewX(key) - if err != nil { - return nil, ErrInvalidCrypto - } - - if len(ciphertext) < chacha20poly1305.NonceSizeX { - return nil, ErrInvalidCrypto - } - - nonce := ciphertext[:chacha20poly1305.NonceSizeX] - ciphertextBody := ciphertext[chacha20poly1305.NonceSizeX:] - - plaintext, err := aead.Open(nil, nonce, ciphertextBody, nil) - if err != nil { - return nil, ErrInvalidCrypto - } - - return plaintext, ErrNone -} - -// Xor performs byte-wise XOR of data with a repeating key. -// Warning: This is NOT cryptographically secure, use only for basic obfuscation. -func Xor(data []byte, key []byte) []byte { - for i := range data { - data[i] ^= key[i%len(key)] - } - return data -} diff --git a/pkg/protocol/errors.go b/pkg/protocol/errors.go index 26eddca..7ccec9c 100644 --- a/pkg/protocol/errors.go +++ b/pkg/protocol/errors.go @@ -2,7 +2,8 @@ package protocol import ( - "proxyblob/pkg/transport" + "errors" + "net" ) // Protocol error codes for agent-server communication. @@ -21,11 +22,12 @@ const ( ErrPacketSendFailed byte = 14 // Packet transmission failed ErrHandlerStopped byte = 15 // Protocol handler is not running ErrUnexpectedPacket byte = 16 // Received unexpected packet type + ErrBufferFull byte = 17 // Per-connection delivery buffer is full // Transport errors (20-29) - ErrTransportClosed byte = transport.ErrTransportClosed // Transport layer terminated - ErrTransportTimeout byte = transport.ErrTransportTimeout // Transport operation timed out - ErrTransportError byte = transport.ErrTransportError // Transport operation failed + ErrTransportClosed byte = 20 // Transport layer terminated + ErrTransportTimeout byte = 21 // Transport operation timed out + ErrTransportError byte = 22 // Transport operation failed // SOCKS errors (30-39) ErrInvalidSocksVersion byte = 30 // Unsupported SOCKS protocol version @@ -40,5 +42,45 @@ const ( // Packet errors (40-49) ErrInvalidPacket byte = 40 // Malformed packet structure - ErrInvalidCrypto byte = 41 // Cryptographic operation failed ) + +// MapNetError converts a network error to an appropriate protocol error code. +// This consolidates error mapping logic used throughout the codebase. +func MapNetError(err error) byte { + if err == nil { + return ErrNone + } + + // Check for timeout + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + return ErrTTLExpired + } + + // Check for net.OpError (most common network error type) + if opErr, ok := err.(*net.OpError); ok { + if errors.Is(opErr, net.ErrClosed) { + return ErrTransportClosed + } + switch opErr.Op { + case "dial": + return ErrNetworkUnreachable + case "read", "write": + return ErrHostUnreachable + case "listen": + return ErrNetworkUnreachable + } + } + + // Check for DNS errors + if _, ok := err.(*net.DNSError); ok { + return ErrHostUnreachable + } + + // Check for closed connection + if errors.Is(err, net.ErrClosed) { + return ErrTransportClosed + } + + // Default to connection refused + return ErrConnectionRefused +} diff --git a/pkg/protocol/netconn.go b/pkg/protocol/netconn.go new file mode 100644 index 0000000..8f1894c --- /dev/null +++ b/pkg/protocol/netconn.go @@ -0,0 +1,155 @@ +package protocol + +import ( + "context" + "io" + "net" + "sync" + "time" + + "github.com/google/uuid" +) + +// ProtocolConn implements net.Conn by wrapping the protocol layer. +// This allows using standard io.Copy for data transfer, just like armon/go-socks5. +type ProtocolConn struct { + id uuid.UUID + handler *BaseHandler + readBuffer chan []byte // internal buffer for received data + readData []byte // partial read buffer + readOffset int + closed chan struct{} + closeOnce sync.Once // guards SendClose (protocol-level close) + shutdownOnce sync.Once // guards close(closed) (local shutdown) + ctx context.Context +} + +// NewProtocolConn creates a virtual connection that uses the protocol layer. +func NewProtocolConn(ctx context.Context, id uuid.UUID, handler *BaseHandler) *ProtocolConn { + return &ProtocolConn{ + id: id, + handler: handler, + readBuffer: make(chan []byte, 100), // Buffered channel for received data + closed: make(chan struct{}), + ctx: ctx, + } +} + +// Read implements net.Conn.Read - reads data received via protocol. +func (c *ProtocolConn) Read(b []byte) (n int, err error) { + // If we have buffered data, return it first + if c.readOffset < len(c.readData) { + n = copy(b, c.readData[c.readOffset:]) + c.readOffset += n + if c.readOffset >= len(c.readData) { + c.readData = nil + c.readOffset = 0 + } + return n, nil + } + + // Wait for new data from protocol layer + select { + case <-c.closed: + return 0, io.EOF + case <-c.ctx.Done(): + return 0, io.EOF + case data, ok := <-c.readBuffer: + if !ok { + return 0, io.EOF + } + + // Copy as much as possible to b + n = copy(b, data) + // If there's leftover data, store it + if n < len(data) { + c.readData = data + c.readOffset = n + } + return n, nil + } +} + +// Write implements net.Conn.Write - sends data via protocol. +func (c *ProtocolConn) Write(b []byte) (n int, err error) { + select { + case <-c.closed: + return 0, io.ErrClosedPipe + case <-c.ctx.Done(): + return 0, io.ErrClosedPipe + default: + } + + errCode := c.handler.SendData(c.id, b) + if errCode != ErrNone { + return 0, io.ErrClosedPipe + } + return len(b), nil +} + +// Shutdown closes the ProtocolConn's closed channel without sending a CmdClose packet. +// Called by Connection.Close() to unblock delivery goroutines and ProtocolConn.Read(). +// Safe to call multiple times. +func (c *ProtocolConn) Shutdown() { + c.shutdownOnce.Do(func() { + close(c.closed) + }) +} + +// Close implements net.Conn.Close. +// Sends a CmdClose packet and then shuts down locally. +func (c *ProtocolConn) Close() error { + c.closeOnce.Do(func() { + c.handler.SendClose(c.id, ErrNone) + }) + c.Shutdown() + return nil +} + +// LocalAddr implements net.Conn.LocalAddr (returns dummy address). +func (c *ProtocolConn) LocalAddr() net.Addr { + return &protocolAddr{network: "protocol", address: c.id.String()} +} + +// RemoteAddr implements net.Conn.RemoteAddr (returns dummy address). +func (c *ProtocolConn) RemoteAddr() net.Addr { + return &protocolAddr{network: "protocol", address: "remote"} +} + +// SetDeadline implements net.Conn.SetDeadline (not implemented). +func (c *ProtocolConn) SetDeadline(t time.Time) error { + return nil // Not implemented for protocol connections +} + +// SetReadDeadline implements net.Conn.SetReadDeadline (not implemented). +func (c *ProtocolConn) SetReadDeadline(t time.Time) error { + return nil // Not implemented for protocol connections +} + +// SetWriteDeadline implements net.Conn.SetWriteDeadline (not implemented). +func (c *ProtocolConn) SetWriteDeadline(t time.Time) error { + return nil // Not implemented for protocol connections +} + +// DeliverData is called by the protocol handler when data arrives for this connection. +func (c *ProtocolConn) DeliverData(data []byte) { + select { + case <-c.closed: + return + case c.readBuffer <- data: + } +} + +// protocolAddr implements net.Addr for protocol connections. +type protocolAddr struct { + network string + address string +} + +func (a *protocolAddr) Network() string { + return a.network +} + +func (a *protocolAddr) String() string { + return a.address +} diff --git a/pkg/protocol/protocol.go b/pkg/protocol/protocol.go index 8995e2b..7a4e097 100644 --- a/pkg/protocol/protocol.go +++ b/pkg/protocol/protocol.go @@ -1,6 +1,5 @@ // Package protocol implements the communication protocol between proxy server and agent. -// It provides packet encoding/decoding, connection management, and secure data transfer -// using ChaCha20-Poly1305. +// It provides packet encoding/decoding and connection management. // // The protocol uses a binary packet format with fixed-size headers and variable-length // payloads. Each packet contains a command type, connection ID, and optional data. @@ -51,6 +50,11 @@ func NewPacket(command byte, connectionID uuid.UUID, data []byte) *Packet { } } +// EncodedSize returns the total size of the encoded packet in bytes. +func (p *Packet) EncodedSize() int { + return HeaderSize + len(p.Data) +} + // Encode serializes the packet into a byte slice following the protocol format. // Returns nil if any encoding operation fails. func (p *Packet) Encode() []byte { @@ -78,8 +82,8 @@ func (p *Packet) Encode() []byte { } // Decode deserializes a byte slice into a protocol packet. -// Returns nil if the data is malformed, incomplete, or contains an invalid command. -// The input must contain at least HeaderSize bytes and match the encoded length. +// Returns nil if the data is malformed or contains an invalid command. +// The input must be EXACTLY one complete packet (aznet ensures this). func Decode(data []byte) *Packet { if len(data) < HeaderSize { return nil @@ -94,6 +98,8 @@ func Decode(data []byte) *Packet { copy(id[:], data[CommandSize:CommandSize+UUIDSize]) dataLength := binary.BigEndian.Uint32(data[CommandSize+UUIDSize : HeaderSize]) + + // Check exact size - aznet returns complete messages, so size must match exactly if uint32(len(data)) != uint32(HeaderSize)+dataLength { return nil } diff --git a/pkg/proxy/server/errors.go b/pkg/proxy/server/errors.go index 2c5db6a..6d7a09d 100644 --- a/pkg/proxy/server/errors.go +++ b/pkg/proxy/server/errors.go @@ -40,5 +40,4 @@ var ErrToString = map[byte]string{ // Protocol packet errors protocol.ErrInvalidPacket: "invalid protocol packet structure", - protocol.ErrInvalidCrypto: "invalid cryptographic operation", } \ No newline at end of file diff --git a/pkg/proxy/server/server.go b/pkg/proxy/server/server.go index 16e19af..71e5abc 100644 --- a/pkg/proxy/server/server.go +++ b/pkg/proxy/server/server.go @@ -1,7 +1,7 @@ // Package proxy implements a SOCKS proxy server. // It accepts client connections and forwards traffic through transport channels -// to remote agents. The server manages connection lifecycle, encryption, and -// bidirectional data transfer. +// to remote agents. The server manages connection lifecycle and bidirectional +// data transfer. package proxy import ( @@ -10,7 +10,6 @@ import ( "io" "net" "proxyblob/pkg/protocol" - "proxyblob/pkg/transport" "time" "github.com/google/uuid" @@ -28,11 +27,11 @@ type ProxyServer struct { Listener net.Listener } -// NewProxyServer creates a proxy server instance with the given transport. -// The transport is used for communication with remote agents. -func NewProxyServer(ctx context.Context, transport transport.Transport) *ProxyServer { +// NewProxyServer creates a proxy server instance with the given connection. +// The connection is used for communication with remote agents. +func NewProxyServer(ctx context.Context, conn net.Conn) *ProxyServer { server := &ProxyServer{} - server.BaseHandler = protocol.NewBaseHandler(ctx, transport) + server.BaseHandler = protocol.NewBaseHandler(ctx, conn) server.PacketHandler = server return server } @@ -69,8 +68,7 @@ func (s *ProxyServer) OnNew(connectionID uuid.UUID, data []byte) byte { return protocol.ErrUnexpectedPacket } -// OnAck processes connection acknowledgments from agents. It derives a shared -// encryption key using the agent's public key and updates the connection state. +// OnAck processes connection acknowledgments from agents. // Returns an error code indicating success or failure. func (s *ProxyServer) OnAck(connectionID uuid.UUID, data []byte) byte { value, ok := s.Connections.Load(connectionID) @@ -79,34 +77,20 @@ func (s *ProxyServer) OnAck(connectionID uuid.UUID, data []byte) byte { } conn := value.(*protocol.Connection) - if conn.State != protocol.StateNew { + // Check if connection already established (ProtocolConn should be nil for new connections) + if conn.ProtocolConn != nil { return protocol.ErrInvalidState } - clientPublicKey := data[:32] - - // At this point,conn.SecretKey contains [24B nonce][32B server private key] - nonce := conn.SecretKey[:24] - serverPrivateKey := conn.SecretKey[24:] - - // Derive the shared key - symmetricKey, errCode := protocol.DeriveKey(serverPrivateKey, clientPublicKey, nonce) - if errCode != protocol.ErrNone { - return errCode - } - - // Store the symmetric key - conn.SecretKey = symmetricKey - - // Signal connection acknowledgment (non-blocking) - conn.ReadBuffer <- []byte{} - conn.State = protocol.StateConnected + // Create the virtual protocol connection (marks connection as established) + conn.ProtocolConn = protocol.NewProtocolConn(s.Ctx, connectionID, s.BaseHandler) + conn.StartDelivery() conn.LastActivity = time.Now() return protocol.ErrNone } -// OnData processes data received from agents. It decrypts the data and forwards -// it to the client. Returns an error code indicating success or failure. +// OnData processes data received from agents and forwards it to the client. +// Returns an error code indicating success or failure. func (s *ProxyServer) OnData(connectionID uuid.UUID, data []byte) byte { value, ok := s.Connections.Load(connectionID) if !ok { @@ -115,20 +99,13 @@ func (s *ProxyServer) OnData(connectionID uuid.UUID, data []byte) byte { conn := value.(*protocol.Connection) conn.LastActivity = time.Now() - decrypted, errCode := protocol.Decrypt(conn.SecretKey, data) - if errCode != protocol.ErrNone { - return errCode - } - data = decrypted - - // Writing to client is handled by forwardToClient goroutine - select { - case <-s.Ctx.Done(): - return protocol.ErrConnectionClosed - case conn.ReadBuffer <- data: - return protocol.ErrNone - + // Non-blocking delivery to per-connection goroutine + if conn.ProtocolConn != nil { + if !conn.Deliver(data) { + return protocol.ErrBufferFull + } } + return protocol.ErrNone } // OnClose handles connection termination from agents. It cleans up the @@ -141,7 +118,18 @@ func (s *ProxyServer) OnClose(connectionID uuid.UUID, errorCode byte) byte { conn := value.(*protocol.Connection) conn.Close() s.Connections.Delete(connectionID) - return errorCode + return protocol.ErrNone +} + +// cleanupConnection closes all connection resources and removes from connection map. +// This helper reduces code duplication in handleConnection. +func (s *ProxyServer) cleanupConnection(connID uuid.UUID, clientConn net.Conn, proxyConn *protocol.Connection) { + clientConn.Close() + if proxyConn.ProtocolConn != nil { + proxyConn.ProtocolConn.Close() + } + proxyConn.Close() + s.Connections.Delete(connID) } // acceptLoop accepts incoming TCP connections and spawns goroutines to handle @@ -178,6 +166,11 @@ func (s *ProxyServer) acceptLoop() { func (s *ProxyServer) handleConnection(clientConn net.Conn) { defer clientConn.Close() + // Enable TCP_NODELAY to disable Nagle's algorithm for better TLS performance + if tcpConn, ok := clientConn.(*net.TCPConn); ok { + tcpConn.SetNoDelay(true) + } + connID := uuid.New() proxyConn := protocol.NewConnection(connID) s.Connections.Store(proxyConn.ID, proxyConn) @@ -189,114 +182,71 @@ func (s *ProxyServer) handleConnection(clientConn net.Conn) { return } - // 2. Wait for agent acknowledgment with timeout - select { - case <-s.Ctx.Done(): - s.SendClose(connID, protocol.ErrHandlerStopped) - s.Connections.Delete(connID) - return - case <-time.After(5 * time.Second): - s.SendClose(connID, protocol.ErrTransportTimeout) - s.Connections.Delete(connID) - return - case <-proxyConn.ReadBuffer: - // Agent acknowledged connection - } - - // 3. Connection established, start bidirectional forwarding - proxyConn.State = protocol.StateConnected - proxyConn.LastActivity = time.Now() - - errCh := make(chan byte, 2) - go s.forwardToAgent(clientConn, proxyConn, errCh) - go s.forwardToClient(clientConn, proxyConn, errCh) - - // Wait for error, closed connection, or context cancellation - select { - case <-s.Ctx.Done(): - // Context cancelled, closing connection - case <-proxyConn.Closed: - // Connection closed by agent - case errCode := <-errCh: - if errCode != protocol.ErrNone && errCode != protocol.ErrConnectionClosed { - log.Error().Str("msg", ErrToString[errCode]).Msg("Connection error") - } - } - - // Clean up the connection - s.SendClose(connID, protocol.ErrConnectionClosed) - proxyConn.Close() - s.Connections.Delete(connID) -} - -// forwardToAgent reads data from the client connection and forwards it to -// the remote agent. It continues until an error occurs or the connection -// is closed. -func (s *ProxyServer) forwardToAgent(clientConn net.Conn, proxyConn *protocol.Connection, errCh chan<- byte) { - buffer := make([]byte, 64*1024) + // 2. Wait for agent acknowledgment with timeout (ProtocolConn will be created in OnAck) + timeout := time.After(5 * time.Second) + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() for { - // Check early termination conditions - if proxyConn.State == protocol.StateClosed { - return - } - select { case <-s.Ctx.Done(): + s.SendClose(connID, protocol.ErrHandlerStopped) + s.Connections.Delete(connID) + return + case <-timeout: + s.SendClose(connID, protocol.ErrTransportTimeout) + s.Connections.Delete(connID) + return + case <-ticker.C: + if proxyConn.ProtocolConn != nil { + // Agent acknowledged connection + goto connected + } + } + } + +connected: + // 3. Connection established, start bidirectional forwarding using io.Copy + errCh := make(chan error, 2) + + // Client โ†’ Agent + go func() { + _, err := io.Copy(proxyConn.ProtocolConn, clientConn) + errCh <- err + }() + + // Agent โ†’ Client + go func() { + _, err := io.Copy(clientConn, proxyConn.ProtocolConn) + errCh <- err + }() + + // Wait for BOTH directions to complete before cleaning up + var err1, err2 error + for i := 0; i < 2; i++ { + select { + case <-s.Ctx.Done(): + s.cleanupConnection(connID, clientConn, proxyConn) return case <-proxyConn.Closed: + s.cleanupConnection(connID, clientConn, proxyConn) return - default: - // Continue processing - } - - n, err := clientConn.Read(buffer) - if err != nil { - if errors.Is(err, io.EOF) { - errCh <- protocol.ErrConnectionClosed - } else if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - errCh <- protocol.ErrTransportTimeout + case err := <-errCh: + if err1 == nil { + err1 = err } else { - // General network error - errCh <- protocol.ErrNetworkUnreachable - } - return - } - - errCode := s.SendData(proxyConn.ID, buffer[:n]) - if errCode != protocol.ErrNone { - errCh <- protocol.ErrPacketSendFailed - return - } - - proxyConn.LastActivity = time.Now() - } -} - -// forwardToClient reads data from the connection's read buffer and forwards -// it to the client. It continues until an error occurs or the connection -// is closed. -func (s *ProxyServer) forwardToClient(clientConn net.Conn, proxyConn *protocol.Connection, errCh chan<- byte) { - for { - select { - case <-s.Ctx.Done(): - return - case <-proxyConn.Closed: - return - case data := <-proxyConn.ReadBuffer: - proxyConn.LastActivity = time.Now() - _, err := clientConn.Write(data) - if err != nil { - if errors.Is(err, io.EOF) { - errCh <- protocol.ErrConnectionClosed - } else if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - errCh <- protocol.ErrTransportTimeout - } else { - // General network error - errCh <- protocol.ErrNetworkUnreachable - } - return + err2 = err } } } + + // Both directions finished - NOW clean up + s.cleanupConnection(connID, clientConn, proxyConn) + + // Log errors if any + for _, err := range []error{err1, err2} { + if err != nil && !errors.Is(err, io.EOF) { + log.Debug().Err(err).Str("conn_id", connID.String()).Msg("Connection closed with error") + } + } } diff --git a/pkg/proxy/socks/connect.go b/pkg/proxy/socks/connect.go index 5271dfb..e5152e3 100644 --- a/pkg/proxy/socks/connect.go +++ b/pkg/proxy/socks/connect.go @@ -2,6 +2,8 @@ package proxy import ( "encoding/binary" + "errors" + "fmt" "io" "net" "time" @@ -36,38 +38,33 @@ func (h *SocksHandler) handleConnect(conn *protocol.Connection, cmdData []byte) return errCode } + fmt.Printf("[CONNECT] %s\n", target) + // Establish TCP connection to target targetConn, err := net.DialTimeout("tcp", target, 10*time.Second) if err != nil { - // Map network error to appropriate SOCKS5 error code - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - errCode = protocol.ErrTTLExpired - } else if opErr, ok := err.(*net.OpError); ok { - if opErr.Op == "dial" { - errCode = protocol.ErrNetworkUnreachable - } else if opErr.Op == "read" { - errCode = protocol.ErrHostUnreachable - } - } else if _, ok := err.(*net.DNSError); ok { - errCode = protocol.ErrHostUnreachable - } else { - errCode = protocol.ErrConnectionRefused - } - - // Send failure response with appropriate code + // Map network error to appropriate protocol error code + errCode = protocol.MapNetError(err) h.SendError(conn, errCode) return errCode } + // Enable TCP_NODELAY to disable Nagle's algorithm for better TLS performance + if tcpConn, ok := targetConn.(*net.TCPConn); ok { + tcpConn.SetNoDelay(true) + } + // Send success response + // Use stack allocation for fixed-size response (10 bytes) localAddr := targetConn.LocalAddr().(*net.TCPAddr) - response := make([]byte, 10) - response[0] = Version5 - response[1] = Succeeded - response[2] = 0x00 - response[3] = IPv4 - copy(response[4:8], localAddr.IP.To4()) - binary.BigEndian.PutUint16(response[8:], uint16(localAddr.Port)) + var responseBuf [10]byte + responseBuf[0] = Version5 + responseBuf[1] = Succeeded + responseBuf[2] = 0x00 + responseBuf[3] = IPv4 + copy(responseBuf[4:8], localAddr.IP.To4()) + binary.BigEndian.PutUint16(responseBuf[8:], uint16(localAddr.Port)) + response := responseBuf[:] errCode = h.SendData(conn.ID, response) if errCode != protocol.ErrNone { @@ -75,98 +72,81 @@ func (h *SocksHandler) handleConnect(conn *protocol.Connection, cmdData []byte) return protocol.ErrPacketSendFailed } - // Store connection and set state + // Store connection (state is already established via ProtocolConn) conn.Conn = targetConn - conn.State = protocol.StateConnected // Start data transfer return h.handleTCPDataTransfer(conn, targetConn) } // handleTCPDataTransfer manages bidirectional data transfer for TCP connections. -// It spawns two goroutines: -// - One reads from client and writes to target -// - One reads from target and writes to client +// Uses io.Copy for efficient data transfer: io.Copy(dst, src) // // The transfer continues until either: // - The connection is closed by either end // - The context is canceled // - An error occurs func (h *SocksHandler) handleTCPDataTransfer(conn *protocol.Connection, tcpConn net.Conn) byte { - // Create channels for communication - clientToTarget := make(chan []byte) - targetToClient := make(chan []byte) - errorCh := make(chan byte, 2) + errCh := make(chan error, 2) - // Read from SOCKS client and forward to target + // Client โ†’ Target go func() { - for { - select { - case <-conn.Closed: - return - case <-h.Ctx.Done(): - return - case data, ok := <-conn.ReadBuffer: - if !ok { - return - } - clientToTarget <- data - } + _, err := io.Copy(tcpConn, conn.ProtocolConn) + // Half-close: close write side of target when clientโ†’target finishes + if tcpCloser, ok := tcpConn.(*net.TCPConn); ok { + tcpCloser.CloseWrite() } + errCh <- err }() - // Read from target and forward to SOCKS client + // Target โ†’ Client go func() { - buffer := make([]byte, 128*1024) - for { - n, err := tcpConn.Read(buffer) - if err != nil { - if err == io.EOF { - errorCh <- protocol.ErrConnectionClosed - } else if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - errorCh <- protocol.ErrTTLExpired - } else { - errorCh <- protocol.ErrHostUnreachable - } - return - } - // Copy data since buffer will be reused - data := make([]byte, n) - copy(data, buffer[:n]) - targetToClient <- data - } + _, err := io.Copy(conn.ProtocolConn, tcpConn) + errCh <- err }() - // Main data transfer loop - for { + // Wait for BOTH directions to complete (don't exit early on first error!) + // DO NOT listen to conn.Closed here - it will abort the wait loop prematurely! + var err1, err2 error + for i := 0; i < 2; i++ { select { - case <-conn.Closed: - tcpConn.Close() - return protocol.ErrNone - case <-h.Ctx.Done(): tcpConn.Close() + conn.ProtocolConn.Close() return protocol.ErrHandlerStopped - case errCode := <-errorCh: + case <-conn.Closed: tcpConn.Close() - return errCode - - case data := <-clientToTarget: - _, err := tcpConn.Write(data) - if err != nil { - tcpConn.Close() - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - return protocol.ErrTTLExpired - } - return protocol.ErrHostUnreachable + conn.ProtocolConn.Close() + // Drain any remaining errors + select { + case <-errCh: + default: } - case data := <-targetToClient: - errCode := h.SendData(conn.ID, data) - if errCode != protocol.ErrNone { - tcpConn.Close() - return protocol.ErrPacketSendFailed + return protocol.ErrNone + + case err := <-errCh: + if err1 == nil { + err1 = err + } else { + err2 = err } } } + + // Both directions finished - NOW it's safe to close + tcpConn.Close() + conn.ProtocolConn.Close() + + // Check for errors (ignore normal EOF and closed connections) + for _, err := range []error{err1, err2} { + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) { + // Use helper to map network errors to protocol errors + if errCode := protocol.MapNetError(err); errCode != protocol.ErrTransportClosed { + return errCode + } + } + } + + return protocol.ErrNone } diff --git a/pkg/proxy/socks/socks.go b/pkg/proxy/socks/socks.go index f86ecc0..d726f4f 100644 --- a/pkg/proxy/socks/socks.go +++ b/pkg/proxy/socks/socks.go @@ -5,10 +5,11 @@ package proxy import ( "context" + "io" + "net" "slices" "proxyblob/pkg/protocol" - "proxyblob/pkg/transport" "github.com/google/uuid" ) @@ -20,11 +21,11 @@ type SocksHandler struct { *protocol.BaseHandler } -// NewSocksHandler creates a SOCKS5 handler with the given transport. -// The transport is used for sending and receiving protocol messages. -func NewSocksHandler(ctx context.Context, transport transport.Transport) *SocksHandler { +// NewSocksHandler creates a SOCKS5 handler with the given connection. +// The connection is used for sending and receiving protocol messages. +func NewSocksHandler(ctx context.Context, conn net.Conn) *SocksHandler { handler := &SocksHandler{} - handler.BaseHandler = protocol.NewBaseHandler(ctx, transport) + handler.BaseHandler = protocol.NewBaseHandler(ctx, conn) handler.PacketHandler = handler return handler } @@ -54,27 +55,19 @@ func (h *SocksHandler) OnNew(connectionID uuid.UUID, data []byte) byte { conn := protocol.NewConnection(connectionID) h.Connections.Store(conn.ID, conn) - // Process the server's nonce and public key from data - if len(data) >= 24+32 { - // Extract nonce and server public key - nonce := data[:24] - serverPublicKey := data[24 : 24+32] + // Create the virtual protocol connection + conn.ProtocolConn = protocol.NewProtocolConn(h.Ctx, connectionID, h.BaseHandler) + conn.StartDelivery() - // Store temporary data for key derivation during ACK - tmp := make([]byte, len(nonce)+len(serverPublicKey)) - copy(tmp[:len(nonce)], nonce) - copy(tmp[len(nonce):], serverPublicKey) - conn.SecretKey = tmp - } - - // Send connection acknowledgment - errCode := h.SendConnAck(connectionID) - if errCode != protocol.ErrNone { - return errCode - } - - // Process the connection - go h.processConnection(conn) + // Send ACK and process in a goroutine so ReceiveLoop never blocks on aznet writes + go func() { + errCode := h.SendConnAck(connectionID) + if errCode != protocol.ErrNone { + h.SendClose(connectionID, protocol.ErrConnectionClosed) + return + } + h.processConnection(conn) + }() return protocol.ErrNone } @@ -85,7 +78,7 @@ func (h *SocksHandler) OnAck(connectionID uuid.UUID, data []byte) byte { } // OnData processes incoming data for a connection. -// It decrypts the data and forwards it to the connection's read buffer. +// Data is forwarded directly (handled by transport layer). func (h *SocksHandler) OnData(connectionID uuid.UUID, data []byte) byte { value, ok := h.Connections.Load(connectionID) if !ok { @@ -93,20 +86,13 @@ func (h *SocksHandler) OnData(connectionID uuid.UUID, data []byte) byte { } conn := value.(*protocol.Connection) - decrypted, errCode := protocol.Decrypt(conn.SecretKey, data) - if errCode != protocol.ErrNone { - h.SendClose(connectionID, protocol.ErrInvalidCrypto) - return errCode - } - data = decrypted - - select { - case <-h.Ctx.Done(): - return protocol.ErrConnectionClosed - case conn.ReadBuffer <- data: - return protocol.ErrNone - + // Non-blocking delivery to per-connection goroutine + if conn.ProtocolConn != nil { + if !conn.Deliver(data) { + return protocol.ErrBufferFull + } } + return protocol.ErrNone } // OnClose cleans up resources associated with a connection. @@ -184,28 +170,52 @@ func (h *SocksHandler) SendError(conn *protocol.Connection, errCode byte) { // handleAuthNegotiation processes the client's authentication method selection. // Currently only the NO AUTHENTICATION REQUIRED (0x00) method is supported. func (h *SocksHandler) handleAuthNegotiation(conn *protocol.Connection) byte { - select { - case methods := <-conn.ReadBuffer: - // Currently we only support NoAuth (0x00) - if !slices.Contains(methods, NoAuth) { - h.SendError(conn, protocol.ErrAuthFailed) - return protocol.ErrAuthFailed - } - - // Send response - errCode := h.SendData(conn.ID, []byte{Version5, NoAuth}) - if errCode != protocol.ErrNone { - return errCode - } - - return protocol.ErrNone - - case <-conn.Closed: + // Read SOCKS5 auth packet: [version(1)][nmethods(1)][methods(nmethods)] + // Use stack allocation for small fixed-size header + var headerBuf [2]byte + header := headerBuf[:] + if _, err := io.ReadFull(conn.ProtocolConn, header); err != nil { return protocol.ErrConnectionClosed - - case <-h.Ctx.Done(): - return protocol.ErrHandlerStopped } + + // Check version + if header[0] != Version5 { + return protocol.ErrInvalidSocksVersion + } + + // Read methods + nmethods := int(header[1]) + if nmethods == 0 { + return protocol.ErrInvalidPacket + } + + // Use stack buffer for typical case (most clients send 1-2 methods) + // Max nmethods is 255, but allocate reasonable stack space + var methodsBuf [4]byte + var methods []byte + if nmethods <= 4 { + methods = methodsBuf[:nmethods] + } else { + methods = make([]byte, nmethods) + } + if _, err := io.ReadFull(conn.ProtocolConn, methods); err != nil { + return protocol.ErrConnectionClosed + } + + // Currently we only support NoAuth (0x00) + if !slices.Contains(methods, NoAuth) { + h.SendError(conn, protocol.ErrAuthFailed) + return protocol.ErrAuthFailed + } + + // Send response + response := []byte{Version5, NoAuth} + errCode := h.SendData(conn.ID, response) + if errCode != protocol.ErrNone { + return protocol.ErrConnectionClosed + } + + return protocol.ErrNone } // handleCommand processes SOCKS5 commands from the client. @@ -218,40 +228,76 @@ func (h *SocksHandler) handleAuthNegotiation(conn *protocol.Connection) byte { // // - BIND (0x02): TCP/IP port binding func (h *SocksHandler) handleCommand(conn *protocol.Connection) byte { - select { - case cmdData := <-conn.ReadBuffer: - // Validate command format - if len(cmdData) < 4 { - h.SendError(conn, protocol.ErrInvalidPacket) - return protocol.ErrInvalidPacket - } - - // Check SOCKS version - if cmdData[0] != Version5 { - h.SendError(conn, protocol.ErrInvalidSocksVersion) - return protocol.ErrInvalidSocksVersion - } - - var errCode byte - switch cmdData[1] { - case Connect: - errCode = h.handleConnect(conn, cmdData) - case Bind: - errCode = h.handleBind(conn, cmdData) - case UDPAssociate: - errCode = h.handleUDPAssociate(conn) - default: - h.SendError(conn, protocol.ErrUnsupportedCommand) - return protocol.ErrUnsupportedCommand - } - - return errCode - - case <-conn.Closed: + // Read SOCKS5 command header: [version(1)][cmd(1)][rsv(1)][atyp(1)] + // Use stack allocation for fixed-size header + var headerBuf [4]byte + header := headerBuf[:] + if _, err := io.ReadFull(conn.ProtocolConn, header); err != nil { return protocol.ErrConnectionClosed - case <-h.Ctx.Done(): - return protocol.ErrHandlerStopped } + + // Check SOCKS version + if header[0] != Version5 { + h.SendError(conn, protocol.ErrInvalidSocksVersion) + return protocol.ErrInvalidSocksVersion + } + + cmd := header[1] + atyp := header[3] + + // Read address based on address type + var addr []byte + switch atyp { + case IPv4: + // Use stack allocation for IPv4 (6 bytes: 4 IP + 2 port) + var addrBuf [6]byte + addr = addrBuf[:] + case IPv6: + // Use stack allocation for IPv6 (18 bytes: 16 IP + 2 port) + var addrBuf [18]byte + addr = addrBuf[:] + case Domain: + // Read domain length first + var lenBuf [1]byte + if _, err := io.ReadFull(conn.ProtocolConn, lenBuf[:]); err != nil { + return protocol.ErrConnectionClosed + } + domainLen := int(lenBuf[0]) + // Domain name can be up to 255 bytes, use heap allocation + addr = make([]byte, 1+domainLen+2) // length + domain + port + addr[0] = lenBuf[0] + if _, err := io.ReadFull(conn.ProtocolConn, addr[1:]); err != nil { + return protocol.ErrConnectionClosed + } + default: + h.SendError(conn, protocol.ErrAddressNotSupported) + return protocol.ErrAddressNotSupported + } + + // Read the address and port + if atyp != Domain { + if _, err := io.ReadFull(conn.ProtocolConn, addr); err != nil { + return protocol.ErrConnectionClosed + } + } + + // Build complete command data for handlers + cmdData := append(header, addr...) + + var errCode byte + switch cmd { + case Connect: + errCode = h.handleConnect(conn, cmdData) + case Bind: + errCode = h.handleBind(conn, cmdData) + case UDPAssociate: + errCode = h.handleUDPAssociate(conn) + default: + h.SendError(conn, protocol.ErrUnsupportedCommand) + return protocol.ErrUnsupportedCommand + } + + return errCode } // handleDataTransfer manages the flow of data between client and target. diff --git a/pkg/proxy/socks/udp_associate.go b/pkg/proxy/socks/udp_associate.go index 2f2e828..c5a3927 100644 --- a/pkg/proxy/socks/udp_associate.go +++ b/pkg/proxy/socks/udp_associate.go @@ -31,17 +31,8 @@ func (h *SocksHandler) handleUDPAssociate(conn *protocol.Connection) byte { udpConn, err := net.ListenUDP("udp", udpAddr) if err != nil { - // Determine specific error type - errCode := protocol.ErrGeneralSocksFailure - if opErr, ok := err.(*net.OpError); ok { - if errors.Is(opErr, net.ErrClosed) { - errCode = protocol.ErrTransportClosed - } else if opErr.Op == "listen" { - errCode = protocol.ErrNetworkUnreachable - } - } - - // Send appropriate error response + // Map network error to appropriate protocol error code + errCode := protocol.MapNetError(err) h.SendError(conn, errCode) return errCode } @@ -68,9 +59,8 @@ func (h *SocksHandler) handleUDPAssociate(conn *protocol.Connection) byte { return protocol.ErrPacketSendFailed } - // Store UDP connection and start handling packets + // Store UDP connection and start handling packets (state is already established via ProtocolConn) conn.Conn = udpConn - conn.State = protocol.StateConnected go h.handleUDPPackets(conn) @@ -98,7 +88,7 @@ func (h *SocksHandler) handleUDPAssociate(conn *protocol.Connection) byte { // the context is canceled. func (h *SocksHandler) handleUDPPackets(conn *protocol.Connection) { udpConn := conn.Conn.(*net.UDPConn) - buffer := make([]byte, 64*1024) + buffer := make([]byte, 16*1024) // Reduced from 64KB to 16KB var clientAddr *net.UDPAddr // Create a UDP connection for sending to targets @@ -126,7 +116,7 @@ func (h *SocksHandler) handleUDPPackets(conn *protocol.Connection) { // Start a goroutine to handle responses go func() { - respBuf := make([]byte, 128*1024) + respBuf := make([]byte, 16*1024) // Reduced from 128KB to 16KB for { // Check for cancellation select { @@ -138,8 +128,8 @@ func (h *SocksHandler) handleUDPPackets(conn *protocol.Connection) { // Continue processing } - // Set read deadline - if err := targetConn.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil { + // Set read deadline to 100ms for faster response (reduced from 300ms) + if err := targetConn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { if errors.Is(err, net.ErrClosed) { return } @@ -252,8 +242,8 @@ func (h *SocksHandler) handleUDPPackets(conn *protocol.Connection) { } default: - // Set read deadline to prevent blocking forever - if err := udpConn.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil { + // Set read deadline to 100ms for faster response (reduced from 300ms) + if err := udpConn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { if errors.Is(err, net.ErrClosed) { return } diff --git a/pkg/transport/blob.go b/pkg/transport/blob.go deleted file mode 100644 index a59b55d..0000000 --- a/pkg/transport/blob.go +++ /dev/null @@ -1,266 +0,0 @@ -// Package transport provides interfaces and implementations for communication -// between proxy components. It abstracts the underlying transport mechanism -// and ensures reliable data transfer with proper error handling. -package transport - -import ( - "bytes" - "context" - "errors" - "io" - "time" - - "github.com/Azure/azure-storage-blob-go/azblob" -) - -// Retry configuration for blob operations. -const ( - InitialRetryDelay = 50 * time.Millisecond // Starting delay between retries - MaxRetryDelay = 3 * time.Second // Maximum delay between retries - BackoffFactor = 1.5 // Multiplier for exponential backoff -) - -// BlobTransport implements the Transport interface using Azure Blob Storage. -// It uses separate blobs for reading and writing to provide bidirectional -// communication. All operations are retried with exponential backoff. -type BlobTransport struct { - readBlob azblob.BlockBlobURL // Blob for receiving data - writeBlob azblob.BlockBlobURL // Blob for sending data -} - -// NewBlobTransport creates a transport that uses the provided blobs for -// bidirectional communication. The readBlob is used for receiving data, -// and the writeBlob is used for sending data. -func NewBlobTransport(readBlob, writeBlob azblob.BlockBlobURL) *BlobTransport { - return &BlobTransport{ - readBlob: readBlob, - writeBlob: writeBlob, - } -} - -// Send writes data to the write blob. It blocks until the data is written -// or the context is canceled. Returns an error code indicating success or -// specific failure reason. -func (t *BlobTransport) Send(ctx context.Context, data []byte) byte { - return WriteBlob(ctx, t.writeBlob, data) -} - -// Receive reads and clears data from the read blob. It blocks until data -// is available or the context is canceled. Returns the read data and an -// error code indicating success or failure reason. -func (t *BlobTransport) Receive(ctx context.Context) ([]byte, byte) { - return WaitForData(ctx, t.readBlob) -} - -// IsClosed reports whether the transport is permanently closed. -func (t *BlobTransport) IsClosed(errCode byte) bool { - return errCode == ErrTransportClosed -} - -// WriteBlob attempts to write data to a blob with retry and exponential backoff. -// The operation is retried until successful or the context is canceled. -// Returns an error code indicating success or specific failure reason. -func WriteBlob(ctx context.Context, blobURL azblob.BlockBlobURL, data []byte) byte { - retryDelay := InitialRetryDelay - - // Try the operation with unlimited retries - for { - // Check if blob is empty - isEmpty, errCode := IsBlobEmpty(ctx, blobURL) - if errCode != ErrNone { - return errCode - } - - if !isEmpty { - // If the blob isn't empty, wait before retrying - retryDelay, errCode = WaitDelay(ctx, retryDelay) - if errCode != ErrNone { - return errCode - } - continue - } - - // Reset delay when we find empty blob - retryDelay = InitialRetryDelay - - // Upload data to the blob - _, err := blobURL.Upload( - ctx, - bytes.NewReader(data), - azblob.BlobHTTPHeaders{ContentType: "application/octet-stream"}, - azblob.Metadata{}, - azblob.BlobAccessConditions{}, - azblob.DefaultAccessTier, - nil, - azblob.ClientProvidedKeyOptions{}, - azblob.ImmutabilityPolicyOptions{}, - ) - - if err != nil { - // If upload fails, check context and retry - if ctx.Err() != nil { - return ErrContextCanceled - } - - // Wait before retrying with exponential backoff - retryDelay, errCode = WaitDelay(ctx, retryDelay) - if errCode != ErrNone { - return errCode - } - continue - } - - // Success - return ErrNone - } -} - -// WaitForData polls a blob until data is available, then reads and clears it. -// The operation is retried with exponential backoff until data is found or -// the context is canceled. Returns the read data and an error code indicating -// success or failure reason. -func WaitForData(ctx context.Context, blobURL azblob.BlockBlobURL) ([]byte, byte) { - var data []byte - retryDelay := InitialRetryDelay - - for { - // Check for cancellation - if ctx.Err() != nil { - return nil, ErrContextCanceled - } - - // Poll until the blob contains data - isEmpty, errCode := IsBlobEmpty(ctx, blobURL) - if errCode != ErrNone { - return nil, errCode - } - - if isEmpty { - // If empty, wait before checking again with exponential backoff - retryDelay, errCode = WaitDelay(ctx, retryDelay) - if errCode != ErrNone { - return nil, errCode - } - continue - } - - // Reset delay when we find data - retryDelay = InitialRetryDelay - - // Found data, download the blob content - response, err := blobURL.Download(ctx, 0, azblob.CountToEnd, azblob.BlobAccessConditions{}, false, azblob.ClientProvidedKeyOptions{}) - if err != nil { - return nil, BlobError(err) - } - - bodyReader := response.Body(azblob.RetryReaderOptions{MaxRetryRequests: 3}) - defer bodyReader.Close() - - // Read the data - data, err = io.ReadAll(bodyReader) - if err != nil { - return nil, ErrTransportError - } - - // Clear the blob - no retries here - errCode = ClearBlob(ctx, blobURL) - if errCode != ErrNone { - return nil, errCode - } - - return data, ErrNone - } -} - -// IsBlobEmpty checks if a blob is empty by retrieving its properties. -// Returns true if the blob has zero content length, and an error code -// indicating success or failure reason. -func IsBlobEmpty(ctx context.Context, blobURL azblob.BlockBlobURL) (bool, byte) { - // Check for cancelation - props, err := blobURL.GetProperties(ctx, azblob.BlobAccessConditions{}, azblob.ClientProvidedKeyOptions{}) - if err != nil { - return false, BlobError(err) - } - - return props.ContentLength() == 0, ErrNone -} - -// ClearBlob empties a blob's contents by uploading an empty byte slice. -// The operation is retried with exponential backoff until successful or -// the context is canceled. Returns an error code indicating success or -// specific failure reason. -func ClearBlob(ctx context.Context, blobURL azblob.BlockBlobURL) byte { - var errCode byte - retryDelay := InitialRetryDelay - - // Try the operation with unlimited retries - for { - _, err := blobURL.Upload( - ctx, - bytes.NewReader([]byte{}), - azblob.BlobHTTPHeaders{ContentType: "application/octet-stream"}, - azblob.Metadata{}, - azblob.BlobAccessConditions{}, - azblob.DefaultAccessTier, - nil, - azblob.ClientProvidedKeyOptions{}, - azblob.ImmutabilityPolicyOptions{}, - ) - - if err == nil { - // Success, no error - return ErrNone - } - - // Wait before retrying with exponential backoff - retryDelay, errCode = WaitDelay(ctx, retryDelay) - if errCode != ErrNone { - return errCode - } - } -} - -// BlobError maps Azure Blob Storage errors to transport error codes. -// It handles common error cases like container not found, network issues, -// and authentication failures. -func BlobError(err error) byte { - // Quick check for nil - if err == nil { - return ErrNone - } - - // Check for context cancellation - if errors.Is(err, context.Canceled) { - return ErrContextCanceled - } - - // Check for container-related errors (indicating transport is closed) - if storageErr, ok := err.(azblob.StorageError); ok { - serviceCode := storageErr.ServiceCode() - if serviceCode == azblob.ServiceCodeContainerNotFound || - serviceCode == azblob.ServiceCodeContainerBeingDeleted || - serviceCode == azblob.ServiceCodeAccountBeingCreated { - return ErrTransportClosed - } - } - - // All other errors are treated as general transport errors - return ErrTransportError -} - -// WaitDelay implements exponential backoff for retry operations. -// It sleeps for the current delay and returns the next delay duration, -// which is the current delay multiplied by BackoffFactor, capped at -// MaxRetryDelay. Returns an error code if the context is canceled. -func WaitDelay(ctx context.Context, retryDelay time.Duration) (time.Duration, byte) { - select { - case <-ctx.Done(): - return 0, ErrContextCanceled - case <-time.After(retryDelay): - retryDelay = time.Duration(float64(retryDelay) * BackoffFactor) - if retryDelay > MaxRetryDelay { - retryDelay = MaxRetryDelay - } - return retryDelay, ErrNone - } -} diff --git a/pkg/transport/transport.go b/pkg/transport/transport.go deleted file mode 100644 index f8fcbb0..0000000 --- a/pkg/transport/transport.go +++ /dev/null @@ -1,38 +0,0 @@ -// Package transport provides interfaces and implementations for communication -// between proxy components. It abstracts the underlying transport mechanism -// and ensures reliable data transfer with proper error handling. -package transport - -import ( - "context" -) - -// Error codes for transport operations. -const ( - ErrNone byte = 0 // Operation completed successfully - ErrContextCanceled byte = 2 // Context was canceled during operation - - // Transport errors (20-29) - ErrTransportClosed byte = 20 // Transport is permanently closed - ErrTransportTimeout byte = 21 // Operation exceeded time limit - ErrTransportError byte = 22 // Generic transport error -) - -// Transport defines an interface for bidirectional packet communication. -// Implementations must ensure reliable delivery and proper error handling. -// All methods are safe for concurrent use. -type Transport interface { - // Send transmits data to the recipient. It blocks until the data is sent - // or the context is canceled. Returns an error code indicating success - // or specific failure reason. - Send(ctx context.Context, data []byte) byte - - // Receive waits for and returns available data. It blocks until data - // is available or the context is canceled. Returns the received data - // and an error code indicating success or failure reason. - Receive(ctx context.Context) ([]byte, byte) - - // IsClosed reports whether the transport is permanently closed. - // The error code parameter helps determine the closure reason. - IsClosed(byte) bool -}