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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ Unix 目录权限为 `0700`、文件为 `0600`;Windows 需另行限制 ACL。
./bin/autocar cert --hosts relay.example.com,203.0.113.10 --cert server.crt --key server.key
```

证书和私钥必须使用不同输出文件;同一目录中的文件名也不能仅大小写不同。
相对/绝对路径或父目录符号链接指向同一输出文件时,会在写入前拒绝。

服务端的 UDP 与 TCP 可以使用相同端口号;这里先使用非特权端口:

```bash
Expand Down
5 changes: 3 additions & 2 deletions internal/proxy/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,9 @@ type trackedConn struct {
// waiting for upstream setup need not be reading the socket; net/http's reader
// can also stop after caching a pipelined byte. Socket closure alone therefore
// need not cancel that work. The frontend calls this once per accepted
// connection, from HTTP ConnContext or SOCKS TCP setup. Registration and Close
// may race during shutdown; either ordering must return a canceled context.
// connection, from HTTP ConnContext or before SOCKS negotiation. Registration
// and Close may race during shutdown; either ordering must return a canceled
// context.
func (c *trackedConn) connectionContext(parent context.Context) context.Context {
ctx, cancel := context.WithCancel(parent)
c.closeMu.Lock()
Expand Down
24 changes: 12 additions & 12 deletions internal/proxy/socks5.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,18 @@ func (s *SOCKS5Server) Shutdown(ctx context.Context) error {

func (s *SOCKS5Server) serveConn(client net.Conn) {
defer client.Close()
owner := context.Background()
if tracked, ok := client.(*trackedConn); ok {
// Own authentication, setup, and the established TCP relay from the
// start. Closing the socket must also interrupt work that is not doing
// socket I/O, without coupling it to a stage-specific deadline.
owner = tracked.connectionContext(owner)
}
if s.cfg.handshakeTimeout > 0 {
_ = client.SetDeadline(time.Now().Add(s.cfg.handshakeTimeout))
}

if err := s.negotiate(client); err != nil {
if err := s.negotiate(owner, client); err != nil {
return
}
request, err := readSOCKSRequest(client)
Expand Down Expand Up @@ -132,17 +139,10 @@ func (s *SOCKS5Server) serveConn(client net.Conn) {
s.serveUDPAssociate(client, request)
return
}
s.serveConnect(client, request)
s.serveConnect(owner, client, request)
}

func (s *SOCKS5Server) serveConnect(client net.Conn, request socksRequest) {
owner := context.Background()
if tracked, ok := client.(*trackedConn); ok {
// Bind this caller's setup wait and established relay to full socket
// close, not to a half-close or the independent setup deadline. Do not
// add a payload-consuming reader or close the shared upstream dialer.
owner = tracked.connectionContext(owner)
}
func (s *SOCKS5Server) serveConnect(owner context.Context, client net.Conn, request socksRequest) {
ctx := owner
var cancel context.CancelFunc
if s.cfg.dialTimeout > 0 {
Expand Down Expand Up @@ -623,7 +623,7 @@ wait:
}
}

func (s *SOCKS5Server) negotiate(conn net.Conn) error {
func (s *SOCKS5Server) negotiate(owner context.Context, conn net.Conn) error {
methods, err := readSOCKSGreeting(conn)
if err != nil {
return err
Expand All @@ -647,7 +647,7 @@ func (s *SOCKS5Server) negotiate(conn net.Conn) error {
_ = writeFull(conn, []byte{userPasswordVersion, 0x01})
return err
}
ctx := context.Background()
ctx := owner
if s.cfg.handshakeTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, s.cfg.handshakeTimeout)
Expand Down
140 changes: 140 additions & 0 deletions internal/proxy/socks5_auth_shutdown_cancel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package proxy

import (
"context"
"errors"
"io"
"net"
"sync"
"sync/atomic"
"testing"
"time"

"github.com/cppla/autocar/internal/transport"
)

func TestSOCKS5ForcedShutdownCancelsAuthentication(t *testing.T) {
entered := make(chan context.Context, 1)
authDone := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
var dialCalls atomic.Int64
server, err := NewSOCKS5Server(Config{
Dialer: transport.DialFunc(func(context.Context, string, string) (net.Conn, error) {
dialCalls.Add(1)
return nil, errors.New("authentication must finish before dialing")
}),
HandshakeTimeout: time.Hour,
Authenticator: AuthFunc(func(ctx context.Context, _, _ string) bool {
defer close(authDone)
entered <- ctx
select {
case <-ctx.Done():
case <-release: // Independent cleanup only, never evidence of cancellation.
}
return false
}),
})
if err != nil {
t.Fatal(err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
managed, err := server.lifecycle.manage(listener)
if err != nil {
_ = listener.Close()
t.Fatal(err)
}
accepted := make(chan error, 1)
handlerDone := make(chan struct{})
var client net.Conn
t.Cleanup(func() {
releaseOnce.Do(func() { close(release) })
if client != nil {
_ = client.Close()
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_ = server.Shutdown(ctx)
select {
case <-handlerDone:
t.Log("independent cleanup joined the production authentication handler")
case <-time.After(2 * time.Second):
t.Error("authentication handler did not finish after independent cleanup")
}
})
// Keep the production connection tracking and serveConn implementation;
// expose only the worker's completion so socket closure cannot mask it.
go func() {
defer close(handlerDone)
conn, err := managed.Accept()
accepted <- err
if err == nil {
server.serveConn(conn)
}
}()
client = dialTCP(t, listener.Addr().String())
select {
case err := <-accepted:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("managed listener did not accept the authentication client")
}
socksGreeting(t, client, []byte{socksMethodUserPassword})
mustWrite(t, client, []byte{userPasswordVersion, 1, 'u', 1, 'p'})
var authCtx context.Context
select {
case authCtx = <-entered:
case <-time.After(2 * time.Second):
t.Fatal("production negotiation did not enter the authenticator")
}
if err := authCtx.Err(); err != nil {
t.Fatalf("authentication was canceled before shutdown: %v", err)
}

shutdownCtx, cancel := context.WithCancel(context.Background())
cancel() // Force the close path without a timer or a scheduling delay.
shutdownErr := server.Shutdown(shutdownCtx)
if !errors.Is(shutdownErr, context.Canceled) {
t.Errorf("forced Shutdown = %v, want context canceled", shutdownErr)
}
server.lifecycle.tracker.mu.Lock()
held := len(server.lifecycle.tracker.conns)
server.lifecycle.tracker.mu.Unlock()
if held != 0 {
t.Errorf("forced Shutdown retained %d tracked connections", held)
}
if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatal(err)
}
var one [1]byte
n, readErr := client.Read(one[:])
if n != 0 || !errors.Is(readErr, io.EOF) {
t.Errorf("client after forced Shutdown = %d/%v, want 0/EOF", n, readErr)
}
if dialCalls.Load() != 0 {
t.Errorf("unfinished authentication made %d upstream dial calls", dialCalls.Load())
}
if err := authCtx.Err(); !errors.Is(err, context.Canceled) {
// With neither ctx.Done nor cleanup release closed, the authenticator
// cannot finish. This is a context ownership assertion, not a sleep.
t.Errorf("forced Shutdown left authentication context live: %v; tracked=%d client=%d/%v", err, held, n, readErr)
select {
case <-authDone:
t.Error("authenticator unexpectedly finished without cancellation or cleanup")
default:
t.Log("before independent cleanup: authenticator and serveConn are still blocked")
}
return
}
select {
case <-handlerDone:
t.Logf("forced Shutdown=%v; context canceled; tracker=%d; client EOF; authentication handler joined", shutdownErr, held)
case <-time.After(2 * time.Second):
t.Error("authentication handler did not finish after its context was canceled")
}
}
3 changes: 2 additions & 1 deletion internal/proxy/socks5_tcp_setup_cancel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ func TestSOCKSTCPSetupAlreadyClosedSkipsDial(t *testing.T) {
if err := tracked.Close(); err != nil {
t.Fatal(err)
}
server.serveConnect(tracked, socksRequest{command: socksCommandConnect, address: "127.0.0.1:443"})
owner := tracked.connectionContext(context.Background())
server.serveConnect(owner, tracked, socksRequest{command: socksCommandConnect, address: "127.0.0.1:443"})
if dialCalls != 0 {
t.Fatalf("already closed SOCKS TCP setup started %d remote dials", dialCalls)
}
Expand Down
42 changes: 39 additions & 3 deletions internal/security/cert.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,14 @@ func GenerateSelfSignedCertificate(opts CertificateOptions) (certPEM, keyPEM []b

// WriteSelfSignedCertificate generates and atomically writes a certificate and
// private key. The key is always installed with mode 0600, even when replacing
// a pre-existing file; the certificate is installed with mode 0644.
// a pre-existing file; the certificate is installed with mode 0644. Destinations
// in the same directory must not have equal or case-only-different filenames.
func WriteSelfSignedCertificate(certFile, keyFile string, opts CertificateOptions) error {
if strings.TrimSpace(certFile) == "" || strings.TrimSpace(keyFile) == "" {
return errors.New("security: certificate and key paths are required")
}
if filepath.Clean(certFile) == filepath.Clean(keyFile) {
return errors.New("security: certificate and key paths must be different")
if err := validateCertificateDestinations(certFile, keyFile); err != nil {
return err
}

certPEM, keyPEM, err := GenerateSelfSignedCertificate(opts)
Expand Down Expand Up @@ -159,6 +160,41 @@ func WriteSelfSignedCertificate(certFile, keyFile string, opts CertificateOption
return nil
}

func validateCertificateDestinations(certFile, keyFile string) error {
// Compare the directories the filesystem actually resolves, not just path
// spellings: relative paths and symlinked parents can name the same entry.
// Split preserves symlink/.. semantics that cleaning the path could change.
certDir, certName := filepath.Split(certFile)
keyDir, keyName := filepath.Split(keyFile)
if certDir == "" {
certDir = "."
}
if keyDir == "" {
keyDir = "."
}
certParent, err := os.Stat(certDir)
if err != nil {
return fmt.Errorf("security: certificate directory: %w", err)
}
if !certParent.IsDir() {
return errors.New("security: certificate parent must be a directory")
}
keyParent, err := os.Stat(keyDir)
if err != nil {
return fmt.Errorf("security: private key directory: %w", err)
}
if !keyParent.IsDir() {
return errors.New("security: private key parent must be a directory")
}
// Conservatively disallow case-only names in one directory, including on
// case-sensitive hosts, so a pair is safe on case-insensitive filesystems.
// Do not follow the final component: Rename replaces that directory entry.
if os.SameFile(certParent, keyParent) && strings.EqualFold(certName, keyName) {
return errors.New("security: certificate and key paths must be different")
}
return nil
}

func prepareAtomicFile(destination string, data []byte, mode os.FileMode) (path string, err error) {
dir := filepath.Dir(destination)
base := filepath.Base(destination)
Expand Down
89 changes: 89 additions & 0 deletions internal/security/cert_path_alias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package security

import (
"bytes"
"os"
"path/filepath"
"runtime"
"testing"
)

func TestWriteSelfSignedCertificateRejectsAliasedPaths(t *testing.T) {
workingDirectory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for _, aliasKind := range []string{"absolute-relative", "symlink-parent", "case-only"} {
for _, existing := range []bool{false, true} {
state := "missing"
if existing {
state = "existing"
}
t.Run(aliasKind+"/"+state, func(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "shared.pem")
var alias string
switch aliasKind {
case "absolute-relative":
alias, err = filepath.Rel(workingDirectory, target)
if err != nil {
t.Skipf("cannot express temporary target relative to working directory: %v", err)
}
case "symlink-parent":
link := filepath.Join(t.TempDir(), "parent-link")
if err := os.Symlink(directory, link); err != nil {
if runtime.GOOS == "windows" {
t.Skipf("directory symlink unavailable: %v", err)
}
t.Fatal(err)
}
alias = filepath.Join(link, "shared.pem")
case "case-only":
alias = filepath.Join(directory, "SHARED.PEM")
}
original := []byte("existing destination must stay unchanged\n")
if existing {
if err := os.WriteFile(target, original, 0o600); err != nil {
t.Fatal(err)
}
}
if err := WriteSelfSignedCertificate(target, alias, CertificateOptions{Hosts: []string{"node.example"}}); err == nil {
t.Error("aliased certificate and private-key destinations were accepted")
}
if existing {
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, original) {
t.Error("existing destination was overwritten")
}
} else if _, err := os.Lstat(target); !os.IsNotExist(err) {
t.Errorf("rejected aliases must not create a destination; stat error: %v", err)
}
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatal(err)
}
wantEntries := 0
if existing {
wantEntries = 1
}
if len(entries) != wantEntries {
t.Errorf("rejected aliases left %d directory entries, want %d", len(entries), wantEntries)
}
})
}
}
}

func TestWriteSelfSignedCertificateAllowsSameBasenameInDifferentDirectories(t *testing.T) {
certFile := filepath.Join(t.TempDir(), "same.pem")
keyFile := filepath.Join(t.TempDir(), "same.pem")
if err := WriteSelfSignedCertificate(certFile, keyFile, CertificateOptions{Hosts: []string{"node.example"}}); err != nil {
t.Fatal(err)
}
if _, err := LoadKeyPair(certFile, keyFile); err != nil {
t.Fatalf("different destinations must contain a usable certificate and key: %v", err)
}
}
Loading