Skip to content

Commit 7feb709

Browse files
committed
feat(proxy,telegram): SNI policy evaluation, approval UX improvements, Docker API negotiation
- Add SNI-based policy evaluation as happy path for IP-only SOCKS5 CONNECT requests on TLS ports. Peeks TLS ClientHello after CONNECT success to recover hostname, re-evaluates policy with it, and populates DNS reverse cache for future connections. Falls back to DNS reverse cache for non-TLS or missing SNI. - Fix timeout message format: CancelApproval now preserves the original approval message text and appends the reason instead of replacing the entire message. Timed-out requests now show what destination was being requested. - Add "Always Deny" button to Telegram approval prompts. Saves a persistent deny rule to the store, same as "Always Allow" does for allow rules. - Reorganize approval buttons into two rows to prevent truncation on narrow screens: [Allow | Deny] / [Always Allow | Always Deny]. - Add /start command handler and register bot commands via setMyCommands API so they appear in Telegram's command menu. - Replace hardcoded Docker API v1.25 with version negotiation. Queries /version on the daemon at startup and uses the reported API version. - Document DNS approval flow design in CLAUDE.md: DNS intentionally only blocks denied domains so ask destinations can reach SOCKS5 approval.
1 parent 9c07238 commit 7feb709

11 files changed

Lines changed: 536 additions & 17 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Extends phantom swap to handle OAuth credentials bidirectionally. Static credent
142142
| WebSocket | Handshake headers + text frame phantom swap | Text frame deny + redact rules |
143143
| SSH | Jump host, key from vault | N/A |
144144
| IMAP/SMTP | AUTH command proxy, phantom password swap | N/A |
145-
| DNS | N/A | Domain-level policy (NXDOMAIN for denied) |
145+
| DNS | N/A | Deny-only (NXDOMAIN). See DNS design note below. |
146146
| QUIC/HTTP3 | HTTP/3 MITM via quic-go | Full HTTP/3 request/response |
147147
| APNS | Connection-level allow/deny (port 5223) | N/A |
148148

@@ -162,6 +162,8 @@ Two-phase detection: port-based guess first, then byte-level for non-standard po
162162

163163
`CouldBeAllowed(dest, includeAsk)`: when broker configured, Ask-matching destinations resolve via DNS for approval flow. When no broker, Ask treated as Deny at DNS stage to prevent leaking queries.
164164

165+
**DNS approval design**: The DNS interceptor intentionally only blocks explicitly denied domains (returns NXDOMAIN). All other queries (allow, ask, default) are forwarded to the upstream resolver. This is by design. Policy enforcement for "ask" destinations happens at the SOCKS5 CONNECT layer, not at DNS. Blocking DNS for "ask" destinations would prevent the TCP connection from ever reaching the SOCKS5 handler where the approval flow triggers. The DNS layer populates the reverse DNS cache (IP -> hostname) so the SOCKS5 handler can recover hostnames from IP-only CONNECT requests.
166+
165167
### Audit logger
166168

167169
Optional. JSON lines with blake3 hash chain (`prev_hash` field). Genesis hash: blake3(""). Recovers chain across restarts by reading last line. `sluice audit verify` walks log and reports broken links.

internal/channel/channel.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ const (
4040
ResponseAlwaysAllow
4141
// ResponseDeny rejects the connection request.
4242
ResponseDeny
43+
// ResponseAlwaysDeny rejects the connection and adds a persistent deny rule.
44+
ResponseAlwaysDeny
4345
)
4446

4547
func (r Response) String() string {
@@ -50,6 +52,8 @@ func (r Response) String() string {
5052
return "always_allow"
5153
case ResponseDeny:
5254
return "deny"
55+
case ResponseAlwaysDeny:
56+
return "always_deny"
5357
default:
5458
return "unknown"
5559
}

internal/container/docker_socket.go

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,25 @@ import (
1111
"net/url"
1212
"strconv"
1313
"strings"
14+
"time"
1415
)
1516

16-
// dockerAPIVersion is the minimum Docker Engine API version required.
17-
// v1.25 (Docker 1.13+) supports all operations used here.
18-
const dockerAPIVersion = "v1.25"
17+
// defaultAPIVersion is used when version negotiation fails.
18+
const defaultAPIVersion = "v1.47"
1919

2020
// SocketClient implements ContainerClient using the Docker Engine API
2121
// over a Unix socket. It uses only stdlib net/http with no Docker SDK
2222
// dependency.
2323
type SocketClient struct {
24-
client *http.Client
24+
client *http.Client
25+
apiVersion string
2526
}
2627

2728
// NewSocketClient creates a ContainerClient that communicates with Docker
2829
// over the given Unix socket path (typically /var/run/docker.sock).
30+
// It negotiates the API version with the daemon on creation.
2931
func NewSocketClient(socketPath string) *SocketClient {
30-
return &SocketClient{
32+
c := &SocketClient{
3133
client: &http.Client{
3234
Transport: &http.Transport{
3335
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -36,11 +38,44 @@ func NewSocketClient(socketPath string) *SocketClient {
3638
},
3739
},
3840
},
41+
apiVersion: defaultAPIVersion,
3942
}
43+
c.negotiateVersion()
44+
return c
45+
}
46+
47+
// negotiateVersion queries the Docker daemon for its API version and uses
48+
// the lesser of the daemon's version and our default version.
49+
func (c *SocketClient) negotiateVersion() {
50+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
51+
defer cancel()
52+
53+
// /version works without a version prefix.
54+
req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost/version", nil)
55+
if err != nil {
56+
return
57+
}
58+
resp, err := c.client.Do(req)
59+
if err != nil {
60+
return
61+
}
62+
defer func() { _ = resp.Body.Close() }()
63+
64+
if resp.StatusCode != http.StatusOK {
65+
return
66+
}
67+
68+
var ver struct {
69+
APIVersion string `json:"ApiVersion"`
70+
}
71+
if err := json.NewDecoder(resp.Body).Decode(&ver); err != nil || ver.APIVersion == "" {
72+
return
73+
}
74+
c.apiVersion = "v" + ver.APIVersion
4075
}
4176

4277
func (c *SocketClient) apiURL(path string) string {
43-
return "http://localhost/" + dockerAPIVersion + path
78+
return "http://localhost/" + c.apiVersion + path
4479
}
4580

4681
// InspectContainer returns the state of a container by name.

internal/container/docker_socket_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ func newTestServer(t *testing.T) (*SocketClient, *http.ServeMux, func()) {
2424
sock := filepath.Join(dir, "d.sock")
2525

2626
mux := http.NewServeMux()
27+
// Serve /version so the client can negotiate the API version.
28+
mux.HandleFunc("/version", func(w http.ResponseWriter, _ *http.Request) {
29+
w.Header().Set("Content-Type", "application/json")
30+
json.NewEncoder(w).Encode(map[string]string{"ApiVersion": "1.25"}) //nolint:errcheck
31+
})
2732
listener, err := net.Listen("unix", sock)
2833
if err != nil {
2934
t.Fatalf("listen unix: %v", err)

internal/proxy/dns.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ func (d *DNSInterceptor) ReverseLookup(ip string) string {
4848
return d.reverse.Lookup(ip)
4949
}
5050

51+
// StoreReverse manually adds an IP -> hostname mapping to the reverse cache.
52+
// Used by the SNI recovery path to populate the cache for future connections.
53+
func (d *DNSInterceptor) StoreReverse(ip, hostname string) {
54+
d.reverse.Store(ip, hostname)
55+
}
56+
5157
// dnsTimeout bounds how long a single upstream DNS query can block.
5258
const dnsQueryTimeout = 5 * time.Second
5359

0 commit comments

Comments
 (0)