Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ AutoCAR 在本地提供 SOCKS5、HTTP 和 HTTPS Proxy,在远端解析并连接
HTTP/3/UDP,UDP 不可用时让新 TCP 流继续走 HTTPS/HTTP/2/TCP;SOCKS5 UDP
使用 H3 RFC 9298 CONNECT-UDP,不跨入 H2 fallback。

源代码版本还支持固定上游网站的 HTTP/1.1 WebSocket,并在服务端关闭时回收
升级连接;它仍是网站流量,不是新的代理隧道。限制见
[WebSocket 网站兼容性](docs/WEB_COVER.md#website-websocket-support-in-source-builds)。

v1.0.1 是功能增强与问题修复版本。默认仍为 `native` 服务端与 `auto` 客户端;
**Web-cover 是需要显式开启的实验性功能**,发布不代表其被动抗识别能力已经验证。
变更、升级与限制见 [v1.0.1 发布说明](docs/releases/v1.0.1.md)。
Expand Down
14 changes: 12 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,22 @@ connection selected after GOAWAY run the full bootstrap again. QUIC migration or
NAT rebinding that remains the same `*quic.Conn` retains authentication state.

The cover is either a local static directory or a reverse proxy to one fixed,
operator-authorized HTTP(S) origin. TCP cover responses advertise the bound H3
service through `Alt-Svc`. Neither the cover nor an unauthenticated probe sees
operator-authorized HTTP(S) origin. Ordinary combined H1/H2/H3 cover responses
advertise the bound H3 service through `Alt-Svc`; raw upgraded 101 responses do
not carry that override guarantee. Neither cover nor an unauthenticated probe sees
the `autocar/2` ALPN or native binary request header. This reduces active-probe
exposure but does not prove browser-indistinguishable passive behavior; see
[WEB_COVER.md](WEB_COVER.md).

Source builds additionally permit validated H1.1 WebSocket upgrades to that
same fixed website. Request-local handshake state prevents an optional
transport's response metadata from choosing eligibility; legal duplex bodies
retain optional half-close capability and close once on errors. A TCP-side
physical-connection owner outlives net/http's hijack bookkeeping, so server
shutdown cancels requests and closes upgraded raw sockets without cancelling
shared destination dialers or cover transports. See the WebSocket section of
[WEB_COVER.md](WEB_COVER.md#website-websocket-support-in-source-builds).

The public H1/H2 listener accepts TLS 1.2 and TLS 1.3 for ordinary website
compatibility, while AutoCAR H2 clients and authenticated H2 tunnels require
TLS 1.3. The handler rejects TLS 1.2 from the tunnel path before ticket
Expand Down
9 changes: 9 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,15 @@ headers, and preserves the request path and query. Treat that origin as an
Internet-facing application; do not point it at metadata or control-plane
services.

Source builds after v1.0.1 can forward narrowly validated H1.1 WebSocket GET
upgrades to this same fixed origin; no new flag or arbitrary Upgrade proxy is
introduced. Authorization and nominated hop fields are still stripped.
Printable, parseable malformed upgrades remain ordinary scrubbed website requests;
Go can reject invalid raw/header characters earlier. Invalid upstream
101 responses become generic 502. Shutdown aborts owned upgraded sockets.
See [WebSocket boundaries](WEB_COVER.md#website-websocket-support-in-source-builds)
for handshake, application-policy and close semantics.

`--listen` binds H3/UDP and `--tcp-listen` binds HTTPS/H1/H2. They must use the
same numeric port; if `--tcp-listen` is omitted it inherits `--listen`. Open both
TCP and UDP in the deployment firewall. `--disable-tcp-fallback` is invalid in
Expand Down
9 changes: 9 additions & 0 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ HTTP stream; there is no AutoCAR binary stream preface. H3 additionally
recognizes an authenticated Extended CONNECT with `:protocol=connect-udp` as
described below.

Source builds after v1.0.1 support the configured website's narrowly validated
H1.1 WebSocket GET/101 exchange. It remains fixed-origin cover traffic, never
an authenticated H1 tunnel, arbitrary Upgrade, h2c or H2/H3 WebSocket extended
CONNECT. Ordinary combined H3 cover responses also use the bound Alt-Svc
policy; authenticated writers and standalone H3 policy are unchanged.
See [website WebSocket support](WEB_COVER.md#website-websocket-support-in-source-builds)
for exact validation and abort/cleanup limits; raw upgraded 101 advertisement
is not covered by the ordinary-response override.

An authenticated request that exceeds stream admission receives generic HTTP
`503`; an allowed request whose destination cannot be opened receives generic
HTTP `502`. These responses are available only after a valid credential has
Expand Down
49 changes: 49 additions & 0 deletions docs/WEB_COVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,55 @@ HTTP/2 CONNECT presented to the public origin is always treated as cover,
including when it carries an otherwise valid ticket: the handler removes
`Proxy-Authorization` before delegation and never dials its authority.

### Website WebSocket support in source builds

Source builds after v1.0.1 also forward valid HTTP/1.1 WebSocket upgrades to
the configured `--cover-upstream` origin. This is website traffic, never an
AutoCAR tunnel or a requester-selected upstream. It works on the public TCP
listener with TLS 1.2 or 1.3; static cover and H2/H3 extended CONNECT behavior
are unchanged. An HTTPS website's ordinary H2 connection can remain reusable
while its WebSocket handshake uses H1.

The initial upgrade allowlist is deliberately narrow: body-free H1.1 GET,
one WebSocket Upgrade value, one valid Connection token list containing
Upgrade, version 13, and one canonical base64 key decoding to 16 bytes.
Ambiguous/duplicate fields, other upgrade protocols, body/transfer coding,
Connection close, and nominations of required or negotiation handshake fields
are not upgraded. Parseable, printable malformed handshakes continue as
scrubbed ordinary requests to the same fixed website. Go's existing HTTP
parser or reverse proxy can reject invalid raw/header characters before this
rewrite policy runs; those inputs do not promise an origin request, and the
proxy's early error remains generic 502. Connection-nominated fields and
authorization headers are removed; only the validated
`Connection: Upgrade` / `Upgrade: websocket`
pair is restored. Origin, cookies and ordinary safe negotiation fields remain
end-to-end; the website is responsible for its own access and Origin policy.
An application requiring forwarded Authorization headers remains incompatible
with the cover's intentional credential-stripping policy.

A final 101 must match the validated request and its key, contain the correct
accept value, and supply a duplex body. Unexpected, mismatched or non-duplex
101 responses produce the same generic 502 and close their upstream body.
This follows the [RFC 6455 opening-handshake mechanism](https://www.rfc-editor.org/rfc/rfc6455.html#section-4),
with the additional body-free and single-field restrictions described above.
After the handshake, bytes are relayed without interpreting application frames;
subprotocol and extension negotiation remain the website/client's policy.

Ordinary-response scrubbing can only use nominations still exposed by the
upstream transport. Go's native response parser removes the entire Connection
field when it sees `close`, so additional nominations in that same field are
not available to this handler. This existing parser boundary is unchanged;
explicit authorization-header stripping does not depend on those nominations.

Client disconnection releases the upgraded connection's admission slot.
Server Close or Serve-context cancellation aborts owned physical TCP sockets,
including hijacked upgrades, and cancels their request contexts. This is an
abort operation, not a graceful WebSocket close-frame exchange, and does not
close the shared destination dialer or upstream transport. Ordinary public
responses retain the bound Alt-Svc policy described above; an actual hijacked
101 is written by the reverse proxy's raw upgrade path and is not promised
the same bound-header override.

H2 preserves a client upload half-close while the destination's reply drains.
When the destination itself reaches EOF, H2 finishes that CONNECT response and
stops any remaining upload on that stream; the HTTP handler interface cannot
Expand Down
99 changes: 91 additions & 8 deletions internal/cover/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,38 @@ func NewReverseProxyHandler(origin *url.URL, transport http.RoundTripper) (http.
proxy := &httputil.ReverseProxy{
Transport: &informationalHeaderTransport{base: transport},
Rewrite: func(request *httputil.ProxyRequest) {
upgrade := websocketRequestEligibility(request.In)
request.SetURL(target)
request.Out.Host = target.Host
// ReverseProxy already removes nominated fields before Rewrite,
// then restores its generic Upgrade pair. Retain the original
// nomination boundary, and restore only our validated H1 WebSocket.
removeConnectionNominatedHeaders(request.Out.Header, request.In.Header)
removeUnsafeHeaders(request.Out.Header)
if upgrade.eligible {
request.Out.Header.Set("Connection", "Upgrade")
request.Out.Header.Set("Upgrade", "websocket")
}
request.Out = withWebsocketRequestEligibility(request.Out, upgrade)
},
ModifyResponse: func(response *http.Response) error {
if response.StatusCode == http.StatusSwitchingProtocols {
if response.Body == nil {
// ReverseProxy closes Body unconditionally on hook failure.
response.Body = http.NoBody
}
if err := validateWebsocketResponse(response); err != nil {
return err
}
if err := ownWebsocketResponse(response); err != nil {
return err
}
removeUnsafeHeaders(response.Header)
removeUnsafeHeaders(response.Trailer)
response.Header.Set("Connection", "Upgrade")
response.Header.Set("Upgrade", "websocket")
return nil // Preserve duplex I/O and optional CloseWrite.
}
removeUnsafeHeaders(response.Header)
removeUnsafeHeaders(response.Trailer)
// An upgraded body is duplex, not an HTTP message with trailers.
Expand All @@ -93,7 +120,8 @@ func NewReverseProxyHandler(origin *url.URL, transport http.RoundTripper) (http.
}
return nil
},
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _ error) {
ErrorHandler: func(w http.ResponseWriter, request *http.Request, _ error) {
closeWebsocketResponse(request)
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
},
ErrorLog: log.New(io.Discard, "", 0),
Expand All @@ -111,14 +139,21 @@ type informationalHeaderTransport struct {
}

func (t *informationalHeaderTransport) RoundTrip(request *http.Request) (*http.Response, error) {
// Capture the immutable Rewrite result before an optional custom transport
// sees the request. Its response.Request is not evidence of eligibility.
trusted := websocketResponseRequest(request)
trace := &httptrace.ClientTrace{
Got1xxResponse: func(_ int, header textproto.MIMEHeader) error {
removeUnsafeHeaders(http.Header(header))
return nil
},
}
request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace))
return t.base.RoundTrip(request)
response, err := t.base.RoundTrip(request)
if response != nil && response.StatusCode == http.StatusSwitchingProtocols {
response.Request = trusted
}
return response, err
}

// responseTrailerBody filters fields that a transport discovers only at EOF
Expand Down Expand Up @@ -187,15 +222,63 @@ func normalizeOrigin(origin *url.URL) (*url.URL, error) {
}

func removeUnsafeHeaders(header http.Header) {
for _, value := range header.Values("Connection") {
removeConnectionNominatedHeaders(header, header)
for _, name := range hopByHopHeaders {
deleteHeaderFold(header, name)
}
deleteHeaderFold(header, "Authorization")
}

func removeConnectionNominatedHeaders(header, connectionSource http.Header) {
var nominations map[string]struct{}
var scratch [64]byte
folded := scratch[:0]
for _, value := range headerValuesFold(connectionSource, "Connection") {
for token := range strings.SplitSeq(value, ",") {
if name := strings.TrimSpace(token); name != "" {
header.Del(name)
name := strings.TrimSpace(token)
if !httpToken(name) {
continue
}
folded = foldASCIIHeaderName(folded, name)
if _, exists := nominations[string(folded)]; !exists {
if nominations == nil {
nominations = make(map[string]struct{})
}
// Only a new nomination owns a copied key. Repeated tokens
// reuse scratch; they never rescan the destination header.
nominations[string(folded)] = struct{}{}
}
}
}
for _, name := range hopByHopHeaders {
header.Del(name)
if len(nominations) == 0 {
return
}
// Collect first: header and connectionSource may be the same map, and
// Connection itself may be nominated without hiding later nominations.
for field := range header {
if !httpToken(field) {
continue
}
folded = foldASCIIHeaderName(folded, field)
if _, nominated := nominations[string(folded)]; nominated {
delete(header, field)
}
}
}

// Call only for validated ASCII HTTP tokens. The scratch buffer is local to
// one filtering call; map lookups need no separately retained folded string.
func foldASCIIHeaderName(buffer []byte, name string) []byte {
if cap(buffer) < len(name) {
buffer = make([]byte, len(name))
}
buffer = buffer[:len(name)]
for index := range name {
char := name[index]
if char >= 'A' && char <= 'Z' {
char += 'a' - 'A'
}
buffer[index] = char
}
header.Del("Authorization")
return buffer
}
Loading
Loading