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)_ ๐
-
+
-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
-}