diff --git a/README.md b/README.md index ac7d45e..5d4cba8 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,44 @@ cd autocar go build -trimpath -o autocar ./cmd/autocar ``` -生成独立令牌与含真实 SAN 的证书: +### 新部署:生成两端配置并先检查 + +以下 `init` 与 `--check` 需要包含这些功能的源码构建,已发布的 v1.0.1 不支持。 +将示例地址换成你的中继地址;无需 DNS 查询或连接远端即可生成: + +```bash +./autocar init --server relay.example.com:8443 --out ./autocar-config +./autocar server --config ./autocar-config/server/server.json --check +./autocar client --config ./autocar-config/client/client.json --check +``` + +生成的 `server/` 包含服务端配置、证书、私钥和随机令牌;`client/` 只包含客户端配置、 +信任证书和相同令牌,不含私钥。通过可信通道把两份目录分别送到对应机器,保持权限 +并交给服务运行用户,再执行: + +```bash +# 在中继机器上,路径换成实际 server/ 目录 +autocar server --config /path/to/server/server.json +# 在客户端,路径换成实际 client/ 目录 +autocar doctor --config /path/to/client/client.json --target example.com:443 +autocar client --config /path/to/client/client.json +``` + +`init` 只生成 native/auto 的新部署,不安装服务、不改防火墙、不轮换现有凭据。 +`--out` 的父目录必须存在,目标目录必须不存在;即使已有目录为空也拒绝覆盖。 +默认生成有效期 365 天的自签名证书(`--days` 可调);用 IP 连接但希望使用 DNS 证书名时, +可指定 `--server-name`。服务端监听与 `--server` 相同的数字端口,仍须自行开放 TCP/UDP。 +Unix 目录权限为 `0700`、文件为 `0600`;Windows 需另行限制 ACL。 +不要分享或提交这些目录到 Git,自动生成的 `.gitignore` 只是防误操作。 + +`--check` 只读取本地配置和凭据,不查询 DNS、不开监听端口、不连接中继或目标。 +它不能证明端口可绑定、网络可达或远端证书身份正确;真实链路仍用 `doctor` 检查。 +离线检查要求地址使用数字端口;正常启动的服务名端口用法保留。 +`check` 不能写入 JSON 配置,必须在命令行显式指定。 + +### 手动配置 + +也可分别生成独立令牌与含真实 SAN 的证书: ```bash ./autocar token --out token diff --git a/cmd/autocar/client.go b/cmd/autocar/client.go index f5fc6f6..2e71595 100644 --- a/cmd/autocar/client.go +++ b/cmd/autocar/client.go @@ -27,6 +27,7 @@ func runClient(parent context.Context, args []string) error { fs := flag.NewFlagSet("client", flag.ContinueOnError) var tf tunnelFlags addTunnelFlags(fs, &tf) + check := fs.Bool("check", false, "validate local configuration and exit without DNS, network connections, or listeners (numeric ports required)") socksAddress := fs.String("socks", "127.0.0.1:1080", "local SOCKS5 address; empty disables") httpAddress := fs.String("http", "127.0.0.1:8080", "local HTTP proxy address; empty disables") httpsAddress := fs.String("https", "", "local HTTPS proxy address; empty disables") @@ -47,6 +48,9 @@ func runClient(parent context.Context, args []string) error { if *maxConnections <= 0 { return errors.New("--max-connections must be positive") } + if *idleTimeout < 0 { + return errors.New("--idle-timeout must not be negative") + } if (*proxyCert == "") != (*proxyKey == "") { return errors.New("--proxy-cert and --proxy-key must be supplied together") } @@ -82,12 +86,31 @@ func runClient(parent context.Context, args []string) error { } } } + if *check { + if err := checkClientAddresses(tf, *socksAddress, *httpAddress, *httpsAddress); err != nil { + return err + } + } + var proxyCertificate tls.Certificate + if *httpsAddress != "" { + var err error + proxyCertificate, err = security.LoadKeyPair(*proxyCert, *proxyKey) + if err != nil { + return err + } + } dialer, err := buildTunnelDialer(tf) if err != nil { return err } defer dialer.Close() + if *check { + // Transport constructors validate credentials and options, but open no + // connections. Close the prepared dialer without ever calling Dial. + reportLocalConfigurationCheck("client") + return nil + } proxyConfig := proxy.Config{ Dialer: dialer, Authenticator: authenticator, @@ -140,16 +163,10 @@ func runClient(parent context.Context, args []string) error { closePrepared() return fmt.Errorf("listen HTTPS proxy: %w", err) } - certificate, err := security.LoadKeyPair(*proxyCert, *proxyKey) - if err != nil { - listener.Close() - closePrepared() - return err - } listener = tls.NewListener(listener, &tls.Config{ MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{certificate}, + Certificates: []tls.Certificate{proxyCertificate}, NextProtos: []string{"http/1.1"}, }) server, err := proxy.NewHTTPServer(proxyConfig) diff --git a/cmd/autocar/config_file.go b/cmd/autocar/config_file.go index fda1015..7d44b68 100644 --- a/cmd/autocar/config_file.go +++ b/cmd/autocar/config_file.go @@ -49,7 +49,7 @@ func parseFlagsWithConfig(fs *flag.FlagSet, args []string) error { sort.Strings(keys) for _, name := range keys { f := fs.Lookup(name) - if f == nil || name == "config" { + if f == nil || name == "config" || name == "check" { return fmt.Errorf("unknown or unsupported config option %q for %s", name, fs.Name()) } value, err := commandConfigValue(f, values[name]) diff --git a/cmd/autocar/init.go b/cmd/autocar/init.go new file mode 100644 index 0000000..dc99560 --- /dev/null +++ b/cmd/autocar/init.go @@ -0,0 +1,245 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net" + "net/netip" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/cppla/autocar/internal/security" +) + +func runInit(args []string) error { + return runInitWith(args, os.Stdout) +} + +// init creates a native deployment bundle only. It never contacts the relay, +// installs services, opens a firewall, or overwrites an existing output path. +func runInitWith(args []string, output io.Writer) error { + fs := flag.NewFlagSet("init", flag.ContinueOnError) + server := fs.String("server", "", "client-visible relay host:numeric-port (required; bracket IPv6)") + serverName := fs.String("server-name", "", "certificate DNS name or IP; defaults to the relay host") + directory := fs.String("out", "autocar-config", "new private output directory; must not already exist") + days := fs.Int("days", 365, "self-signed certificate validity in days (1-1825)") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return errors.New("init does not accept positional arguments") + } + if strings.TrimSpace(*directory) == "" { + return errors.New("--out must name a new directory") + } + if *days < 1 || *days > 1825 { + return errors.New("--days must be between 1 and 1825") + } + host, port, err := validateInitServer(*server) + if err != nil { + return err + } + name := *serverName + if name == "" { + name = host + } + if err := validateInitHost(name); err != nil { + return errors.New("--server-name must be a DNS name or IP without a port, wildcard, or zone") + } + cert, key, err := security.GenerateSelfSignedCertificate(security.CertificateOptions{ + Hosts: []string{name}, ValidFor: time.Duration(*days) * 24 * time.Hour, + Organization: "AutoCAR", + }) + if err != nil { + return errors.New("could not generate a certificate; check the relay name and system randomness") + } + random := make([]byte, 32) + if _, err := rand.Read(random); err != nil { + return fmt.Errorf("generate shared token: %w", err) + } + token := []byte(base64.RawURLEncoding.EncodeToString(random) + "\n") + serverConfig, err := json.MarshalIndent(map[string]any{ + "protocol": "native", "listen": ":" + port, + "cert": "server.crt", "key": "server.key", "token-file": "relay-token", + }, "", " ") + if err != nil { + return err + } + clientConfig, err := json.MarshalIndent(map[string]any{ + "server": net.JoinHostPort(host, port), "server-name": name, + "ca": "server.crt", "token-file": "relay-token", "transport": "auto", + }, "", " ") + if err != nil { + return err + } + files := []initBundleFile{ + {".gitignore", []byte("*\n")}, + {"server/.gitignore", []byte("*\n")}, {"client/.gitignore", []byte("*\n")}, + {"server/server.crt", cert}, {"server/server.key", key}, {"server/relay-token", token}, + {"client/server.crt", cert}, {"client/relay-token", token}, + {"server/server.json", append(serverConfig, '\n')}, + {"client/client.json", append(clientConfig, '\n')}, + {"README.txt", []byte(initBundleInstructions)}, + } + if err := writeInitBundle(*directory, files); err != nil { + return err + } + _, err = fmt.Fprintf(output, "Created native configuration bundle in %q.\n"+ + "Copy only server/ to the relay and only client/ to the client over an authenticated channel.\n"+ + "The server private key is not included in client/. Both directories contain a secret token.\n"+ + "See README.txt for offline checks and startup. No service or network connection was started.\n", *directory) + if err != nil { + return errors.New("bundle was created, but writing the completion message failed; do not overwrite it") + } + return nil +} + +func validateInitServer(server string) (host, port string, err error) { + host, port, err = net.SplitHostPort(server) + if err != nil || validateInitHost(host) != nil || port == "" { + return "", "", errors.New("--server must be host:numeric-port (use [IPv6-address]:port)") + } + for _, digit := range port { + if digit < '0' || digit > '9' { + return "", "", errors.New("--server port must be an integer from 1 through 65535") + } + } + number, err := strconv.ParseUint(port, 10, 16) + if err != nil || number == 0 { + return "", "", errors.New("--server port must be an integer from 1 through 65535") + } + return host, strconv.FormatUint(number, 10), nil +} + +func validateInitHost(host string) error { + if address, err := netip.ParseAddr(host); err == nil { + if address.Zone() != "" { + return errors.New("relay address must not contain a zone") + } + address = address.Unmap() + if address.IsUnspecified() || address.IsMulticast() || address == netip.AddrFrom4([4]byte{255, 255, 255, 255}) { + return errors.New("relay address must be a specific unicast address without a zone") + } + return nil + } + name := strings.TrimSuffix(host, ".") + if name == "" || len(name) > 253 { + return errors.New("invalid DNS name length") + } + for _, label := range strings.Split(name, ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return errors.New("invalid DNS label") + } + for _, letter := range label { + if !(letter >= 'a' && letter <= 'z' || letter >= 'A' && letter <= 'Z' || letter >= '0' && letter <= '9' || letter == '-') { + return errors.New("DNS labels must use ASCII letters, digits, and hyphens; use punycode for international names") + } + } + } + return nil +} + +type initBundleFile struct { + path string + data []byte +} + +func writeInitBundle(directory string, files []initBundleFile) error { + // Mkdir, not MkdirAll, reserves a new destination atomically. Even an empty + // existing directory or a dangling symlink must never be reused. + if err := os.Mkdir(directory, 0o700); err != nil { + return fmt.Errorf("create new --out directory (parent must exist; existing paths are never reused): %w", err) + } + if err := writeInitBundleContents(directory, files); err != nil { + // Do not recursively remove a user-selected path on failure. An incomplete + // bundle remains private and can be inspected before choosing a new path. + return fmt.Errorf("incomplete bundle left in --out; do not deploy it: %w", err) + } + return nil +} + +func writeInitBundleContents(directory string, files []initBundleFile) error { + if err := os.Chmod(directory, 0o700); err != nil { + return err + } + for _, name := range []string{"server", "client"} { + path := filepath.Join(directory, name) + if err := os.Mkdir(path, 0o700); err != nil { + return err + } + if err := os.Chmod(path, 0o700); err != nil { + return err + } + } + for _, file := range files { + if err := writeNewInitFile(filepath.Join(directory, filepath.FromSlash(file.path)), file.data); err != nil { + return fmt.Errorf("write bundle file %s: %w", file.path, err) + } + } + return nil +} + +func writeNewInitFile(path string, data []byte) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer file.Close() + if err := file.Chmod(0o600); err != nil { + return err + } + if _, err := file.Write(data); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + return file.Close() +} + +const initBundleInstructions = `AutoCAR native deployment bundle + +This is a private, self-signed-certificate deployment, not a publicly trusted +website certificate. Keep the private key only on the relay. The client directory +contains the trust anchor and shared token but never the private key. + +1. Transfer server/ to the relay and client/ to your client using an authenticated + channel. Never publish this bundle or commit it to Git. Generated .gitignore + files reduce accidental Git adds, but do not replace secure storage. Preserve permissions + and ensure each file is owned/readable by the service user. On Unix, directories + are 0700 and files are 0600. On Windows, restrict access using filesystem ACLs; + Unix mode bits do not secure the bundle there. +2. On the relay, from server/: + autocar server --config server.json --check + autocar server --config server.json +3. On your client, from client/: + autocar client --config client.json --check + autocar doctor --config client.json --target example.com:443 + autocar client --config client.json + The doctor step contacts the relay and the target; choose a target you are + authorized to test. A successful TCP open does not validate UDP or application + traffic. --check only checks local configuration, not connectivity. + +Use a build with init, --config and --check support on both hosts, not the old +v1.0.1 binary. Paths inside JSON are relative to the configuration file, so the +directories can be moved independently. Existing CLI flags override JSON. + +Open the relay's configured TCP and UDP port in your firewall before real use. +No service installation, firewall changes, DNS lookup or network test was done +by init. Low ports may require a capability or external port mapping; do not run +the entire service as root merely for a low port. The local SOCKS5/HTTP proxies +remain loopback-only. Existing destination policy and native/auto defaults apply. + +The generated certificate expires; plan renewal and transfer the replacement +trust anchor through an authenticated channel. init is for NEW deployments only, +not credential rotation. It refuses existing output directories. If initialization +fails, any incomplete private bundle is left in place; do not deploy it. +` diff --git a/cmd/autocar/init_integration_test.go b/cmd/autocar/init_integration_test.go new file mode 100644 index 0000000..79eb090 --- /dev/null +++ b/cmd/autocar/init_integration_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "flag" + "io" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/cppla/autocar/internal/config" + "github.com/cppla/autocar/internal/security" + "github.com/cppla/autocar/internal/tunnel" +) + +// This checks generated credentials/configuration against real native +// transports, not a full CLI deployment. runServer deliberately blocks loopback +// destinations even with --allow-private; only this test's transport harness +// uses an ordinary local dialer. Production destination policy is unchanged. +func TestInitGeneratedBundleCarriesNativeTraffic(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + payload, err := io.ReadAll(io.LimitReader(r.Body, 64<<10)) + if err != nil { + http.Error(w, "read test payload", http.StatusBadRequest) + return + } + _, _ = w.Write(payload) + })) + t.Cleanup(func() { + target.CloseClientConnections() + target.Close() + }) + + dir := filepath.Join(t.TempDir(), "generated bundle") + // The target supplies a real ephemeral port to init. Relay endpoints are + // overridden below with the independently bound QUIC/TLS ephemeral ports; + // nothing reserves and releases a port in anticipation of another listener. + if err := runInitWith([]string{ + "--server", target.Listener.Addr().String(), "--out", dir, + }, io.Discard); err != nil { + t.Fatal(err) + } + + serverFlags := flag.NewFlagSet("server", flag.ContinueOnError) + protocol := serverFlags.String("protocol", "", "") + listen := serverFlags.String("listen", "", "") + certFile := serverFlags.String("cert", "", "") + keyFile := serverFlags.String("key", "", "") + tokenFile := serverFlags.String("token-file", "", "") + if err := parseFlagsWithConfig(serverFlags, []string{ + "--config", filepath.Join(dir, "server", "server.json"), + "--listen=127.0.0.1:0", + }); err != nil { + t.Fatal(err) + } + if *protocol != "native" { + t.Fatalf("generated protocol = %q, want native", *protocol) + } + certificate, err := security.LoadKeyPair(*certFile, *keyFile) + if err != nil { + t.Fatal(err) + } + token, err := config.LoadSecret(*tokenFile, "") + if err != nil { + t.Fatal(err) + } + tlsConfig, err := security.NewServerTLSConfig(security.ServerTLSOptions{ + Certificates: []tls.Certificate{certificate}, + }) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + startRelay := func(name string, relay interface { + Serve(context.Context) error + Close() error + }) { + done := make(chan error, 1) + go func() { done <- relay.Serve(ctx) }() + t.Cleanup(func() { + cancel() + _ = relay.Close() + select { + case err := <-done: + if err != nil { + t.Errorf("%s relay shutdown: %v", name, err) + } + case <-time.After(5 * time.Second): + t.Errorf("%s relay did not shut down", name) + } + }) + } + quicRelay, err := tunnel.ListenQUIC(tunnel.QUICServerConfig{ + Address: *listen, Token: token, TLSConfig: tlsConfig.Clone(), + Dialer: &net.Dialer{Timeout: 5 * time.Second}, + }) + if err != nil { + t.Fatal(err) + } + startRelay("QUIC", quicRelay) + tlsRelay, err := tunnel.ListenTLS(tunnel.TLSServerConfig{ + Address: *listen, Token: token, TLSConfig: tlsConfig.Clone(), + Dialer: &net.Dialer{Timeout: 5 * time.Second}, + }) + if err != nil { + t.Fatal(err) + } + startRelay("TLS", tlsRelay) + + for _, mode := range []string{"quic", "tls", "auto"} { + t.Run(mode, func(t *testing.T) { + var tf tunnelFlags + clientFlags := flag.NewFlagSet("client", flag.ContinueOnError) + addTunnelFlags(clientFlags, &tf) + endpoint := quicRelay.Addr().String() + if mode == "tls" { + endpoint = tlsRelay.Addr().String() + } + args := []string{ + "--config", filepath.Join(dir, "client", "client.json"), + "--server", endpoint, "--open-timeout=10s", + } + if mode == "auto" { + // Exercise init's default mode rather than overriding it. + args = append(args, "--fallback-server", tlsRelay.Addr().String()) + } else { + args = append(args, "--transport", mode) + } + if err := parseFlagsWithConfig(clientFlags, args); err != nil { + t.Fatal(err) + } + if tf.mode != mode { + t.Fatalf("parsed transport = %q, want %q", tf.mode, mode) + } + dialer, err := buildTunnelDialer(tf) + if err != nil { + t.Fatal(err) + } + defer dialer.Close() + probeCtx, stopProbe := context.WithTimeout(ctx, 10*time.Second) + defer stopProbe() + conn, err := dialer.DialContext(probeCtx, "tcp", target.Listener.Addr().String()) + if err != nil { + t.Fatalf("authenticated tunnel open: %v", err) + } + defer conn.Close() + deadline, _ := probeCtx.Deadline() + if err := conn.SetDeadline(deadline); err != nil { + t.Fatal(err) + } + payload := bytes.Repeat([]byte("generated bundle native traffic\n"), 1024) + request, err := http.NewRequestWithContext(probeCtx, http.MethodPost, target.URL, bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + if err := request.Write(conn); err != nil { + t.Fatalf("write tunneled payload: %v", err) + } + response, err := http.ReadResponse(bufio.NewReader(conn), request) + if err != nil { + t.Fatalf("read tunneled response: %v", err) + } + defer response.Body.Close() + got, err := io.ReadAll(io.LimitReader(response.Body, int64(len(payload))+1)) + if err != nil || response.StatusCode != http.StatusOK || !bytes.Equal(got, payload) { + t.Fatalf("payload roundtrip: status=%d bytes=%d error=%v", response.StatusCode, len(got), err) + } + wantTransport := mode + if mode == "auto" { + wantTransport = "quic" + } + reporter, ok := dialer.(selectedTransportReporter) + if !ok || reporter.SelectedTransport() != wantTransport { + t.Fatalf("successful %s exchange did not report transport %s", mode, wantTransport) + } + }) + } +} diff --git a/cmd/autocar/init_test.go b/cmd/autocar/init_test.go new file mode 100644 index 0000000..c74a3a1 --- /dev/null +++ b/cmd/autocar/init_test.go @@ -0,0 +1,269 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "flag" + "io" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "github.com/cppla/autocar/internal/config" + "github.com/cppla/autocar/internal/security" +) + +func TestInitCreatesPrivatePortableMatchingBundle(t *testing.T) { + for _, test := range []struct { + server string + name string + }{ + {"relay.example.com:8443", ""}, + {"127.0.0.1:443", "relay.example.com"}, + {"[::1]:8443", ""}, + } { + t.Run(test.server+test.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "new bundle") + var stdout bytes.Buffer + args := []string{"--server", test.server, "--out", dir, "--days=2"} + if test.name != "" { + args = append(args, "--server-name", test.name) + } + if err := runInitWith(args, &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "No service or network connection was started") { + t.Fatal("initialization output did not state its offline scope") + } + files := snapshotInitBundle(t, dir) + wantFiles := []string{".gitignore", "server/.gitignore", "client/.gitignore", "README.txt", "server/server.json", "server/server.crt", "server/server.key", "server/relay-token", "client/client.json", "client/server.crt", "client/relay-token"} + if len(files) != len(wantFiles) { + t.Fatalf("unexpected bundle file count: %d", len(files)) + } + for _, name := range wantFiles { + if _, ok := files[name]; !ok { + t.Fatalf("missing bundle file: %s", name) + } + if runtime.GOOS != "windows" { + info, err := os.Stat(filepath.Join(dir, filepath.FromSlash(name))) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("file %s must have mode 0600", name) + } + } + } + if runtime.GOOS != "windows" { + for _, name := range []string{".", "server", "client"} { + info, err := os.Stat(filepath.Join(dir, name)) + if err != nil || info.Mode().Perm() != 0o700 { + t.Fatalf("directory %s must have mode 0700", name) + } + } + } + token, err := config.LoadSecret(filepath.Join(dir, "server", "relay-token"), "") + if err != nil { + t.Fatal(err) + } + random, err := base64.RawURLEncoding.DecodeString(token) + if err != nil || len(random) != 32 { + t.Fatal("token is not 32 random bytes in base64url") + } + if strings.Contains(stdout.String(), token) || strings.Contains(stdout.String(), "PRIVATE KEY") { + t.Fatal("initialization output disclosed credentials") + } + if files["server/relay-token"] != files["client/relay-token"] || files["server/server.crt"] != files["client/server.crt"] { + t.Fatal("client and server credentials do not match") + } + for _, name := range []string{".gitignore", "server/.gitignore", "client/.gitignore"} { + if files[name] != sha256.Sum256([]byte("*\n")) { + t.Fatal("generated credentials are missing accidental Git-add protection") + } + } + pair, err := security.LoadKeyPair(filepath.Join(dir, "server", "server.crt"), filepath.Join(dir, "server", "server.key")) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(pair.Certificate[0]) + if err != nil || time.Until(cert.NotAfter) < 47*time.Hour || time.Until(cert.NotAfter) > 49*time.Hour { + t.Fatal("unexpected certificate validity") + } + fs := flag.NewFlagSet("client", flag.ContinueOnError) + var tf tunnelFlags + addTunnelFlags(fs, &tf) + if err := parseFlagsWithConfig(fs, []string{"--config", filepath.Join(dir, "client", "client.json")}); err != nil { + t.Fatal(err) + } + if tf.mode != "auto" || tf.systemRoots || tf.server != test.server { + t.Fatal("generated client changed transport, server or trust defaults") + } + if err := cert.VerifyHostname(tf.serverName); err != nil { + t.Fatalf("certificate does not match generated client server name: %v", err) + } + roots, err := security.LoadCertPool(tf.caFile) + if err != nil { + t.Fatal(err) + } + if _, err := cert.Verify(x509.VerifyOptions{DNSName: tf.serverName, Roots: roots}); err != nil { + t.Fatalf("pinned certificate validation: %v", err) + } + dialer, err := buildTunnelDialer(tf) + if err != nil { + t.Fatal(err) + } + _ = dialer.Close() + server, err := readCommandConfig(filepath.Join(dir, "server", "server.json")) + if err != nil || server["protocol"] != "native" || server["allow-private"] != nil || server["disable-tcp-fallback"] != nil { + t.Fatal("generated server changed safe defaults") + } + if err := runInitWith(args, io.Discard); err == nil { + t.Fatal("existing output was accepted") + } + if !reflect.DeepEqual(files, snapshotInitBundle(t, dir)) { + t.Fatal("refused initialization changed existing files") + } + }) + } +} + +func TestInitRejectsInvalidOptionsBeforeWriting(t *testing.T) { + for _, args := range [][]string{ + nil, {"--server", "host"}, {"--server", ":443"}, {"--server", "host:https"}, + {"--server", "host:0"}, {"--server", "host:65536"}, {"--server", "host:+443"}, + {"--server", "user:password@host:443"}, {"--server", "*.example.com:443"}, + {"--server", "[fe80::1%en0]:443"}, {"--server", "0.0.0.0:443"}, + {"--server", "[::]:443"}, {"--server", "224.0.0.1:443"}, + {"--server", "[::ffff:0.0.0.0]:443"}, {"--server", "[::ffff:224.0.0.1]:443"}, + {"--server", "255.255.255.255:443"}, {"--server", "[::ffff:255.255.255.255]:443"}, + {"--server", "[::ffff:192.0.2.1%en0]:8443"}, + {"--server", "[::ffff:192.0.2.1%en0]:8443", "--server-name", "relay.example.com"}, + {"--server", " relay:443"}, {"--server", "relay..example:443"}, + {"--server", "relay:443", "--server-name", "*.example.com"}, + {"--server", "relay:443", "--server-name", "relay:443"}, + {"--server", "relay:443", "--days=0"}, {"--server", "relay:443", "--days=1826"}, + {"--server", "relay:443", "unexpected"}, + } { + dir := filepath.Join(t.TempDir(), "not-created") + full := append([]string{"--out", dir}, args...) + if err := runInitWith(full, io.Discard); err == nil { + t.Fatalf("invalid arguments accepted: %v", args) + } + if _, err := os.Lstat(dir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("invalid arguments created output: %v", err) + } + } +} + +func TestInitRefusesExistingPathsAndMissingParent(t *testing.T) { + parent := t.TempDir() + file := filepath.Join(parent, "file") + if err := os.WriteFile(file, []byte("leave unchanged"), 0o600); err != nil { + t.Fatal(err) + } + paths := []string{file, parent, filepath.Join(parent, "missing", "new")} + if runtime.GOOS != "windows" { + link := filepath.Join(parent, "link") + if err := os.Symlink(filepath.Join(parent, "absent"), link); err != nil { + t.Fatal(err) + } + paths = append(paths, link) + } + for _, path := range paths { + if err := runInitWith([]string{"--server=relay:8443", "--out", path}, io.Discard); err == nil { + t.Fatal("non-new output path was accepted") + } + } + contents, err := os.ReadFile(file) + if err != nil || string(contents) != "leave unchanged" { + t.Fatal("existing file changed") + } +} + +func TestInitIndependentRunsUseDifferentCredentials(t *testing.T) { + parent := t.TempDir() + var previous map[string][32]byte + for _, name := range []string{"first", "second"} { + dir := filepath.Join(parent, name) + if err := runInitWith([]string{"--server=relay:8443", "--out", dir}, io.Discard); err != nil { + t.Fatal(err) + } + next := snapshotInitBundle(t, dir) + if previous != nil && (previous["server/relay-token"] == next["server/relay-token"] || previous["server/server.key"] == next["server/server.key"]) { + t.Fatal("independent deployments reused credentials") + } + previous = next + } +} + +func TestInitHelpAndPartialFailure(t *testing.T) { + dir := filepath.Join(t.TempDir(), "not-created") + if err := runInitWith([]string{"--out", dir, "--help"}, io.Discard); !errors.Is(err, flag.ErrHelp) { + t.Fatalf("help: %v", err) + } + if _, err := os.Lstat(dir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("help wrote output") + } + // Duplicate fixed filenames simulate a mid-bundle filesystem failure. The + // first file must remain intact and the incomplete directory must be kept. + if err := writeInitBundle(dir, []initBundleFile{{"server/item", []byte("first")}, {"server/item", []byte("second")}}); err == nil || !strings.Contains(err.Error(), "incomplete bundle") { + t.Fatalf("partial failure: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, "server", "item")) + if err != nil || string(data) != "first" { + t.Fatal("partial bundle was overwritten or removed") + } +} + +func TestInitGeneratedConfigurationsPassOfflineChecks(t *testing.T) { + clearPreflightEnvironment(t) + dir := filepath.Join(t.TempDir(), "bundle") + if err := runInitWith([]string{"--server=relay.invalid:8443", "--out", dir}, io.Discard); err != nil { + t.Fatal(err) + } + for _, role := range []string{"server", "client"} { + if err := run(context.Background(), []string{role, "--config", filepath.Join(dir, role, role+".json"), "--check"}); err != nil { + t.Fatalf("generated %s offline check: %v", role, err) + } + } + // Confirm this really is a certificate-only client package, not another + // private key hidden behind the trust-anchor filename. + data, err := os.ReadFile(filepath.Join(dir, "client", "server.crt")) + if err != nil { + t.Fatal(err) + } + block, rest := pem.Decode(data) + if block == nil || block.Type != "CERTIFICATE" || len(bytes.TrimSpace(rest)) != 0 { + t.Fatal("client trust anchor has unexpected PEM material") + } +} + +func snapshotInitBundle(t *testing.T, dir string) map[string][32]byte { + t.Helper() + files := make(map[string][32]byte) + err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + name, err := filepath.Rel(dir, path) + if err != nil { + return err + } + files[filepath.ToSlash(name)] = sha256.Sum256(data) + return nil + }) + if err != nil { + t.Fatal(err) + } + return files +} diff --git a/cmd/autocar/main.go b/cmd/autocar/main.go index c354925..b556b87 100644 --- a/cmd/autocar/main.go +++ b/cmd/autocar/main.go @@ -35,6 +35,8 @@ func run(ctx context.Context, args []string) (err error) { err = runServer(ctx, args[1:]) case "cert": err = runCert(args[1:]) + case "init": + err = runInit(args[1:]) case "token": err = runToken(args[1:]) case "bench-server": @@ -65,6 +67,7 @@ func printUsage() { Usage: autocar server [options] run the remote native or web-cover relay autocar client [options] run local SOCKS5 and HTTP(S) proxies + autocar init [options] generate a new private native deployment bundle autocar cert [options] generate a self-signed TLS certificate autocar token [options] generate a strong shared token autocar bench-server [options] run a benchmark source/sink target diff --git a/cmd/autocar/preflight.go b/cmd/autocar/preflight.go new file mode 100644 index 0000000..104ac37 --- /dev/null +++ b/cmd/autocar/preflight.go @@ -0,0 +1,106 @@ +package main + +import ( + "errors" + "fmt" + "log/slog" + "net" + "net/netip" + "strconv" + "strings" + "time" + + "github.com/cppla/autocar/internal/protocol" +) + +// Offline checks deliberately do not use LookupPort or Resolve* functions. +// Named services remain supported during normal startup; --check requires +// numeric ports so even host-specific service resolvers cannot perform I/O. +func checkLocalAddress(option, address string, remote bool) (uint16, error) { + host, portText, err := net.SplitHostPort(address) + if err != nil || (remote && host == "") { + return 0, fmt.Errorf("%s: invalid address; want host:port", option) + } + if strings.ContainsAny(host, "\x00\r\n\t /\\@?#") { + return 0, fmt.Errorf("%s: invalid host syntax", option) + } + if strings.Contains(host, ":") { + if _, err := netip.ParseAddr(host); err != nil { + return 0, fmt.Errorf("%s: invalid IPv6 address", option) + } + } + if portText == "" || strings.IndexFunc(portText, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { + return 0, fmt.Errorf("%s: --check requires a numeric port to avoid service-name resolution", option) + } + port, err := strconv.ParseUint(portText, 10, 16) + if err != nil || (remote && port == 0) { + if remote { + return 0, fmt.Errorf("%s: port must be between 1 and 65535", option) + } + return 0, fmt.Errorf("%s: port must be between 0 and 65535", option) + } + return uint16(port), nil +} + +func checkClientAddresses(flags tunnelFlags, socks, http, https string) error { + if _, err := checkLocalAddress("--server", flags.server, true); err != nil { + return err + } + if flags.fallback != "" { + if _, err := checkLocalAddress("--fallback-server", flags.fallback, true); err != nil { + return err + } + } + for _, endpoint := range []struct{ option, address string }{ + {"--socks", socks}, {"--http", http}, {"--https", https}, + } { + if endpoint.address != "" { + if _, err := checkLocalAddress(endpoint.option, endpoint.address, false); err != nil { + return err + } + } + } + return nil +} + +func checkServerAddresses(mode, udp, tcp string, disableFallback bool) error { + udpPort, err := checkLocalAddress("--listen", udp, false) + if err != nil { + return err + } + if disableFallback { + return nil + } + tcpPort, err := checkLocalAddress("--tcp-listen", tcp, false) + if err != nil { + return err + } + // ListenWeb uses the fixed port when either side requests an ephemeral + // port, or binds TCP first when both are zero. No bind is attempted here. + if mode == "web" && udpPort != 0 && tcpPort != 0 && udpPort != tcpPort { + return errors.New("web TCP and UDP ports must match") + } + return nil +} + +// These bounds mirror the transport constructors, which cannot be invoked +// for a server without binding sockets. Use them on both the check and normal +// startup paths, before any listener is prepared. +func validateServerLocalLimits(token string, handshakeTimeout, dialTimeout time.Duration, maxUpload, maxDownload uint64) error { + if len(token) < protocol.MinTokenLength || len(token) > protocol.MaxTokenLength { + return fmt.Errorf("tunnel: token length must be between %d and %d bytes", protocol.MinTokenLength, protocol.MaxTokenLength) + } + if handshakeTimeout < 0 || dialTimeout < 0 { + return errors.New("--handshake-timeout and --dial-timeout must not be negative") + } + if maxUpload > protocol.MaxRate || maxDownload > protocol.MaxRate { + return errors.New("tunnel: pacing rate exceeds protocol maximum") + } + return nil +} + +func reportLocalConfigurationCheck(command string) { + slog.Info("local configuration check passed", "command", command, + "scope", "local configuration only; no DNS lookup, network connection, listener binding, or file modification performed", + "not_verified", "network reachability, remote certificate identity, port availability, or cover upstream health") +} diff --git a/cmd/autocar/preflight_test.go b/cmd/autocar/preflight_test.go new file mode 100644 index 0000000..8176bd9 --- /dev/null +++ b/cmd/autocar/preflight_test.go @@ -0,0 +1,401 @@ +package main + +import ( + "bytes" + "context" + "errors" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/cppla/autocar/internal/security" +) + +type preflightFiles struct { + directory, cert, key, token, cover string +} + +func newPreflightFiles(t *testing.T) preflightFiles { + t.Helper() + directory := t.TempDir() + files := preflightFiles{ + directory: directory, + cert: filepath.Join(directory, "relay.crt"), + key: filepath.Join(directory, "relay.key"), + token: filepath.Join(directory, "token"), + cover: filepath.Join(directory, "cover"), + } + if err := security.WriteSelfSignedCertificate(files.cert, files.key, security.CertificateOptions{ + Hosts: []string{"relay.invalid"}, ValidFor: time.Hour, + }); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(files.token, []byte("offline-preflight-secret-never-log-this\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(files.cover, 0o700); err != nil { + t.Fatal(err) + } + return files +} + +func (files preflightFiles) clientArgs() []string { + return []string{"--check", "--server", "relay.invalid:443", "--ca", files.cert, "--token-file", files.token} +} + +func (files preflightFiles) serverArgs() []string { + return []string{"--check", "--listen", "relay.invalid:443", "--cert", files.cert, "--key", files.key, "--token-file", files.token} +} + +func denyPreflightDNS(t *testing.T) *atomic.Int32 { + t.Helper() + var calls atomic.Int32 + original := net.DefaultResolver + net.DefaultResolver = &net.Resolver{ + PreferGo: true, + Dial: func(context.Context, string, string) (net.Conn, error) { + calls.Add(1) + return nil, errors.New("unexpected DNS during offline configuration check") + }, + } + t.Cleanup(func() { net.DefaultResolver = original }) + return &calls +} + +func clearPreflightEnvironment(t *testing.T) { + t.Helper() + for _, key := range []string{"AUTOCAR_TOKEN", "AUTOCAR_PROXY_USER", "AUTOCAR_PROXY_PASSWORD"} { + t.Setenv(key, "") + } +} + +func TestPreflightClientAllTransportsStayOffline(t *testing.T) { + clearPreflightEnvironment(t) + files := newPreflightFiles(t) + dnsCalls := denyPreflightDNS(t) + for _, mode := range []string{"auto", "quic", "tls", "web-auto", "h2", "h3"} { + t.Run(mode, func(t *testing.T) { + args := append(files.clientArgs(), "--transport", mode) + if err := runClient(context.Background(), args); err != nil { + t.Fatal(err) + } + }) + } + if got := dnsCalls.Load(); got != 0 { + t.Fatalf("offline checks attempted %d DNS connections", got) + } +} + +func TestPreflightServerModesStayOffline(t *testing.T) { + clearPreflightEnvironment(t) + files := newPreflightFiles(t) + dnsCalls := denyPreflightDNS(t) + var upstreamRequests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + upstreamRequests.Add(1) + })) + defer upstream.Close() + for _, test := range []struct { + name string + args []string + }{ + {"native", nil}, + {"native mTLS", []string{"--client-ca", files.cert}}, + {"web static", []string{"--protocol", "web", "--cover-root", files.cover}}, + {"web unresolved upstream", []string{"--protocol", "web", "--cover-upstream", "https://cover.invalid/"}}, + {"web live upstream", []string{"--protocol", "web", "--cover-upstream", upstream.URL}}, + } { + t.Run(test.name, func(t *testing.T) { + if err := runServer(context.Background(), append(files.serverArgs(), test.args...)); err != nil { + t.Fatal(err) + } + }) + } + if got := dnsCalls.Load(); got != 0 { + t.Fatalf("offline checks attempted %d DNS connections", got) + } + if got := upstreamRequests.Load(); got != 0 { + t.Fatalf("offline checks made %d cover upstream requests", got) + } +} + +func TestPreflightOccupiedPortsAreNotBoundOrDialed(t *testing.T) { + clearPreflightEnvironment(t) + files := newPreflightFiles(t) + tcp, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer tcp.Close() + udp, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: tcp.Addr().(*net.TCPAddr).Port}) + if err != nil { + t.Fatal(err) + } + defer udp.Close() + address := tcp.Addr().String() + for _, mode := range []string{"auto", "quic", "tls", "web-auto", "h2", "h3"} { + args := append(files.clientArgs(), "--server", address, "--transport", mode, "--socks", address, "--http", "") + if err := runClient(context.Background(), args); err != nil { + t.Fatalf("%s client tried to bind or connect: %v", mode, err) + } + } + for _, mode := range []string{"native", "web"} { + args := append(files.serverArgs(), "--listen", address, "--protocol", mode) + if mode == "web" { + args = append(args, "--cover-root", files.cover) + } + if err := runServer(context.Background(), args); err != nil { + t.Fatalf("%s server tried to bind occupied port: %v", mode, err) + } + } + if err := tcp.SetDeadline(time.Now().Add(20 * time.Millisecond)); err != nil { + t.Fatal(err) + } + conn, err := tcp.Accept() + if conn != nil { + conn.Close() + t.Fatal("offline check connected to the configured relay") + } + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("check TCP observer: %v", err) + } + if err := udp.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); err != nil { + t.Fatal(err) + } + _, _, err = udp.ReadFromUDP(make([]byte, 1)) + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("offline check sent a UDP datagram or observer failed: %v", err) + } +} + +func TestPreflightClientRejectsInvalidConfiguration(t *testing.T) { + clearPreflightEnvironment(t) + files := newPreflightFiles(t) + missing := filepath.Join(files.directory, "missing") + for _, test := range []struct { + name, want string + args []string + }{ + {"mode", "invalid --transport", []string{"--transport", "unknown"}}, + {"relay host", "invalid address", []string{"--server", ":443"}}, + {"relay syntax", "invalid host", []string{"--server", "bad host.invalid:443"}}, + {"relay port", "between 1 and 65535", []string{"--server", "relay.invalid:65536"}}, + {"relay zero port", "between 1 and 65535", []string{"--server", "relay.invalid:0"}}, + {"named service", "requires a numeric port", []string{"--server", "relay.invalid:https"}}, + {"fallback", "--fallback-server", []string{"--fallback-server", "relay.invalid:65536"}}, + {"local port", "--socks", []string{"--socks", "127.0.0.1:65536"}}, + {"no listener", "at least one", []string{"--socks", "", "--http", ""}}, + {"public listener", "unauthenticated", []string{"--socks", "0.0.0.0:1080"}}, + {"CA missing", "read CA file", []string{"--ca", missing}}, + {"CA malformed", "parse CA file", []string{"--ca", files.token}}, + {"token missing", "stat secret file", []string{"--token-file", missing}}, + {"mTLS pair missing", "supplied together", []string{"--client-cert", files.cert}}, + {"mTLS key missing", "open TLS private key", []string{"--client-cert", files.cert, "--client-key", missing}}, + {"HTTPS pair missing", "requires --proxy-cert", []string{"--https", "127.0.0.1:8443"}}, + {"HTTPS key missing", "open TLS private key", []string{"--https", "127.0.0.1:8443", "--proxy-cert", files.cert, "--proxy-key", missing}}, + {"idle timeout", "--idle-timeout", []string{"--idle-timeout", "-1s"}}, + {"fallback budget", "fallback retains time", []string{"--quic-attempt-timeout", "15s"}}, + {"H3 profile", "fingerprint", []string{"--transport", "h3", "--h3-fingerprint", "invalid"}}, + } { + t.Run(test.name, func(t *testing.T) { + err := runClient(context.Background(), append(files.clientArgs(), test.args...)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestPreflightServerRejectsInvalidConfiguration(t *testing.T) { + clearPreflightEnvironment(t) + files := newPreflightFiles(t) + missing := filepath.Join(files.directory, "missing") + for _, test := range []struct { + name, want string + args []string + }{ + {"mode", "invalid --protocol", []string{"--protocol", "unknown"}}, + {"listen syntax", "invalid address", []string{"--listen", "relay.invalid"}}, + {"listen port", "between 0 and 65535", []string{"--listen", "relay.invalid:65536"}}, + {"named service", "requires a numeric port", []string{"--listen", ":https"}}, + {"fallback port", "--tcp-listen", []string{"--tcp-listen", "relay.invalid:65536"}}, + {"token missing", "stat secret file", []string{"--token-file", missing}}, + {"certificate missing", "open TLS certificate", []string{"--cert", missing}}, + {"key missing", "open TLS private key", []string{"--key", missing}}, + {"client CA missing", "read client CA", []string{"--client-ca", missing}}, + {"client CA malformed", "parse CA file", []string{"--client-ca", files.token}}, + {"ports policy", "invalid denied port", []string{"--deny-ports", "0"}}, + {"CIDR policy", "invalid denied CIDR", []string{"--deny-cidrs", "not-a-CIDR"}}, + {"dial timeout", "--dial-timeout", []string{"--dial-timeout", "-1s"}}, + {"handshake timeout", "--handshake-timeout", []string{"--handshake-timeout", "-1s"}}, + {"connections", "--max-connections", []string{"--max-connections", "0"}}, + {"UDP sessions", "--max-client-udp-sessions", []string{"--max-udp-sessions", "1"}}, + {"rate bound", "protocol maximum", []string{"--pacing", "fixed-rate", "--max-upload-mbps", "8000001", "--max-download-mbps", "1"}}, + {"web missing root", "static cover", []string{"--protocol", "web", "--cover-root", missing}}, + {"web non-directory root", "not a directory", []string{"--protocol", "web", "--cover-root", files.token}}, + {"web mismatched ports", "ports must match", []string{"--protocol", "web", "--cover-root", files.cover, "--tcp-listen", "relay.invalid:8443"}}, + {"web upstream scheme", "http or https", []string{"--protocol", "web", "--cover-upstream", "file:///tmp/cover"}}, + {"web mTLS", "incompatible", []string{"--protocol", "web", "--cover-root", files.cover, "--client-ca", files.cert}}, + } { + t.Run(test.name, func(t *testing.T) { + err := runServer(context.Background(), append(files.serverArgs(), test.args...)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } + if err := os.WriteFile(files.token, []byte("short"), 0o600); err != nil { + t.Fatal(err) + } + if err := runServer(context.Background(), files.serverArgs()); err == nil || !strings.Contains(err.Error(), "token length") { + t.Fatalf("short token accepted: %v", err) + } +} + +func TestPreflightLocalFilesAndLogs(t *testing.T) { + clearPreflightEnvironment(t) + files := newPreflightFiles(t) + type snapshot struct { + info os.FileInfo + data []byte + } + before := make(map[string]snapshot) + for _, path := range []string{files.cert, files.key, files.token} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + before[path] = snapshot{info, data} + } + var output bytes.Buffer + original := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil))) + t.Cleanup(func() { slog.SetDefault(original) }) + if err := runClient(context.Background(), append(files.clientArgs(), + "--https", "127.0.0.1:8443", "--proxy-cert", files.cert, "--proxy-key", files.key, + "--client-cert", files.cert, "--client-key", files.key, + )); err != nil { + t.Fatal(err) + } + if err := runServer(context.Background(), files.serverArgs()); err != nil { + t.Fatal(err) + } + for path, previous := range before { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, previous.data) || info.Mode() != previous.info.Mode() || !info.ModTime().Equal(previous.info.ModTime()) { + t.Fatalf("offline check modified %s", path) + } + } + for _, expected := range []string{"local configuration only", "no DNS lookup", "remote certificate identity", "port availability", "cover upstream health"} { + if !strings.Contains(output.String(), expected) { + t.Errorf("check output omitted limitation %q: %s", expected, output.String()) + } + } + if strings.Contains(output.String(), "offline-preflight-secret") || strings.Contains(output.String(), "PRIVATE KEY") { + t.Fatalf("check output exposed credentials: %s", output.String()) + } +} + +func TestPreflightCheckCannotBeSetInConfig(t *testing.T) { + for _, text := range []string{`{"check":true}`, `{"check":false}`} { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(text), 0o600); err != nil { + t.Fatal(err) + } + for _, command := range []func(context.Context, []string) error{runClient, runServer} { + err := command(context.Background(), []string{"--config", path, "--check"}) + if err == nil || !strings.Contains(err.Error(), "unsupported config option") { + t.Fatalf("config %s error = %v, want unsupported check option", text, err) + } + } + } +} + +func TestPreflightWebEphemeralPortsAndDisabledFallback(t *testing.T) { + for _, ports := range [][2]string{{":0", ":443"}, {":443", ":0"}, {":0", ":0"}} { + if err := checkServerAddresses("web", ports[0], ports[1], false); err != nil { + t.Errorf("web %v rejected: %v", ports, err) + } + } + if err := checkServerAddresses("native", ":443", "ignored:service", true); err != nil { + t.Fatalf("disabled fallback was checked: %v", err) + } + for _, address := range []string{"[::1]:443", "[fe80::1%eth0]:443", ":0", "relay.invalid:443"} { + if _, err := checkLocalAddress("--listen", address, false); err != nil { + t.Errorf("valid local address %q rejected: %v", address, err) + } + } +} + +func TestServerExplicitEmptyClientCAFailsClosed(t *testing.T) { + clearPreflightEnvironment(t) + files := newPreflightFiles(t) + emptyCA := filepath.Join(files.directory, "empty-ca.pem") + if err := os.WriteFile(emptyCA, nil, 0o600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + for _, check := range []string{"true", "false"} { + t.Run("check="+check, func(t *testing.T) { + args := append(files.serverArgs(), "--check="+check, "--listen", "127.0.0.1:0", "--client-ca", emptyCA) + err := runServer(ctx, args) + if err == nil || !strings.Contains(err.Error(), "client CA") { + t.Fatalf("empty client CA must never disable mTLS, error = %v", err) + } + }) + } +} + +func TestPreflightRejectsInsecureCredentialPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission policy") + } + clearPreflightEnvironment(t) + files := newPreflightFiles(t) + for _, test := range []struct { + name, path string + run func(context.Context, []string) error + args []string + }{ + {"client token", files.token, runClient, files.clientArgs()}, + {"server token", files.token, runServer, files.serverArgs()}, + {"server key", files.key, runServer, files.serverArgs()}, + {"proxy key", files.key, runClient, append(files.clientArgs(), "--https", "127.0.0.1:8443", "--proxy-cert", files.cert, "--proxy-key", files.key)}, + } { + t.Run(test.name, func(t *testing.T) { + if err := os.Chmod(test.path, 0o644); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chmod(test.path, 0o600); err != nil { + t.Error(err) + } + }) + err := test.run(context.Background(), test.args) + if err == nil || !strings.Contains(err.Error(), "permissions") { + t.Fatalf("insecure credentials accepted: %v", err) + } + }) + } +} diff --git a/cmd/autocar/server.go b/cmd/autocar/server.go index 5728012..ab96c8e 100644 --- a/cmd/autocar/server.go +++ b/cmd/autocar/server.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/tls" + "crypto/x509" "errors" "flag" "fmt" @@ -10,7 +11,6 @@ import ( "net" "net/http" "net/url" - "os" "strings" "sync" "time" @@ -24,6 +24,7 @@ import ( func runServer(parent context.Context, args []string) error { fs := flag.NewFlagSet("server", flag.ContinueOnError) + check := fs.Bool("check", false, "validate local configuration and exit without DNS, network connections, or listeners (numeric ports required)") serverProtocolText := fs.String("protocol", "native", "relay protocol: native or web") listen := fs.String("listen", ":443", "primary UDP listen address") tcpListen := fs.String("tcp-listen", "", "TCP listener address; defaults to --listen") @@ -127,20 +128,25 @@ func runServer(parent context.Context, args []string) error { if err != nil { return err } + if err := validateServerLocalLimits(token, *handshakeTimeout, *dialTimeout, maxUpload, maxDownload); err != nil { + return err + } certificate, err := security.LoadKeyPair(*certFile, *keyFile) if err != nil { return err } - var clientCAPEM []byte + var clientCAs *x509.CertPool if *clientCAFile != "" { - clientCAPEM, err = os.ReadFile(*clientCAFile) + // An explicitly configured but empty CA must fail closed. Passing an + // empty PEM slice to NewServerTLSConfig means mTLS was not requested. + clientCAs, err = security.LoadCertPool(*clientCAFile) if err != nil { return fmt.Errorf("read client CA: %w", err) } } tlsConfig, err := security.NewServerTLSConfig(security.ServerTLSOptions{ Certificates: []tls.Certificate{certificate}, - ClientCAPEM: clientCAPEM, + ClientCAs: clientCAs, }) if err != nil { return err @@ -153,6 +159,20 @@ func runServer(parent context.Context, args []string) error { if err != nil { return err } + var coverHandler http.Handler + if serverProtocol == "web" { + coverHandler, err = buildCoverHandler(*coverRoot, *coverUpstream) + if err != nil { + return err + } + } + if *check { + if err := checkServerAddresses(serverProtocol, *listen, *tcpListen, *disableFallback); err != nil { + return err + } + reportLocalConfigurationCheck("server") + return nil + } safeDialer := security.NewSafeDialer(security.SafeDialerOptions{ AllowPrivate: *allowPrivate, DeniedPorts: deniedPorts, @@ -167,10 +187,6 @@ func runServer(parent context.Context, args []string) error { return fmt.Errorf("configure stream admission: %w", err) } if serverProtocol == "web" { - coverHandler, err := buildCoverHandler(*coverRoot, *coverUpstream) - if err != nil { - return err - } webServer, err := tunnel.ListenWeb(tunnel.WebServerConfig{ TCPAddress: *tcpListen, UDPAddress: *listen, @@ -302,7 +318,7 @@ func validateServerProtocolOptions(protocolMode, clientCAFile, coverRoot, coverU return fmt.Errorf("invalid --protocol %q; want native or web", protocolMode) } - if strings.TrimSpace(clientCAFile) != "" { + if clientCAFile != "" { return errors.New("--client-ca is incompatible with --protocol=web because the cover origin must remain publicly reachable") } if disableFallback { diff --git a/cmd/autocar/web_cli_test.go b/cmd/autocar/web_cli_test.go index 9e8b0ef..6cbe666 100644 --- a/cmd/autocar/web_cli_test.go +++ b/cmd/autocar/web_cli_test.go @@ -36,6 +36,7 @@ func TestValidateServerProtocolOptions(t *testing.T) { {name: "web missing cover", protocol: "web", wantError: "exactly one"}, {name: "web ambiguous cover", protocol: "web", root: "/srv/www", upstream: "https://cover.example", wantError: "exactly one"}, {name: "web client CA", protocol: "web", root: "/srv/www", clientCA: "clients.pem", wantError: "incompatible"}, + {name: "web whitespace client CA", protocol: "web", root: "/srv/www", clientCA: " ", wantError: "incompatible"}, {name: "web without H2", protocol: "web", root: "/srv/www", disableFallback: true, wantError: "HTTP/2"}, } for _, test := range tests { diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 83f85dc..e223dbf 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -9,6 +9,48 @@ relay and an `auto` client; installing v1.0.1 does not enable web-cover. ## 1. Build and create credentials +### Generate a new paired bundle + +With a build containing `init` and `--check` (not the published v1.0.1 binary), +you can generate matching native server/client configuration without contacting +any network: + +```bash +go build -trimpath -o autocar ./cmd/autocar +./autocar init --server relay.example.com:8443 --out ./autocar-config +./autocar server --config ./autocar-config/server/server.json --check +./autocar client --config ./autocar-config/client/client.json --check +``` + +Replace the relay address with yours. The output parent directory must exist; +the output directory must NOT exist. Existing paths, including empty directories +and symlinks, are refused. `init` is for new deployments, not credential rotation. +It generates a random 32-byte shared token and an ECDSA self-signed certificate; +`--days` controls certificate validity (default 365, range 1-1825). The default +certificate name comes from the relay host; `--server-name` selects an explicit +DNS/IP certificate identity, for example when connecting to an IP. No DNS lookup +is needed. Plan certificate renewal and trust-anchor redistribution separately. + +Transfer only `server/` to the relay and only `client/` to the client through an +authenticated channel. The private key stays in `server/`. Both directories +contain the shared token and must remain private. Unix directories are `0700` +and files `0600`; Windows deployments must restrict their ACLs independently. +Assign ownership/read access to each host's runtime user. Generated `.gitignore` +files help prevent accidental Git adds; they are not a storage/access boundary. +If writing fails, the incomplete private directory is preserved for inspection; +do not deploy it or expect a retry to overwrite it. + +Run `server --config /path/to/server/server.json` on the relay and +`client --config /path/to/client/client.json` locally. Config-relative paths make +the two directories independently movable. Both TCP and UDP listeners use the +numeric port in `--server`. No firewall, DNS, service installation or system +settings are changed. Low ports still require appropriate capability/port +mapping; don't use root simply to bind a low port. Local proxy listeners stay +loopback-only and the default destination policy is unchanged. For public-CA or +experimental web-cover deployments, use the manual configuration below instead. + +### Manual credentials and runtime user + ```bash go build -trimpath -o autocar ./cmd/autocar sudo install -o root -g root -m 0755 autocar /usr/local/bin/autocar @@ -277,6 +319,19 @@ Server files are separate and cannot be used as client connection profiles. Use a trusted, runtime-readable config and restart the process after editing it. `--help` never reads config files or starts a network probe. +Before starting either endpoint, add `--check` to its normal `client` or `server` +command. It validates local option combinations and credential files without +binding a listener, resolving a hostname, connecting a peer, or modifying files. +Web-cover paths are checked locally but upstream health is not requested. +It can succeed even when the configured ports are occupied or the relay is +unreachable. It does not validate remote certificate identity, reachability, +firewalls, host-local port availability, or permissions needed to bind a port. +Address ports must be numeric for `--check`, avoiding service-name resolution; +normal startup continues to accept its existing service-name syntax. +`--check` is a command-line-only operation: a JSON `check` key is rejected even +if false or overridden, so a saved service configuration cannot silently turn +startup into a one-shot check. Remove the flag to actually run the service. + For routine diagnosis, use the same file with `doctor`, then temporarily require `--transport=tls` (native) or `--transport=h2` (web) to isolate the TCP path. A successful TCP check does not validate UDP. Fallback/recovery applies to new @@ -323,6 +378,15 @@ authorization factor. This is available only in native mode. Web-cover rejects mTLS on both sides so that ordinary visitors can reach the cover site; its per-request HMAC ticket remains mandatory for tunnel access. +An explicitly configured empty or malformed client CA file is an error, not a +request to turn off mTLS. Both normal startup and `--check` reject it. + +CA, certificate and private-key files must be regular files no larger than +4 MiB each. Symlinks to valid regular files are allowed; named pipes and devices +are rejected before opening. Private keys still require Unix mode `0600`. +Keep the configuration directory protected from untrusted writers: these input +checks are not a guarantee against hostile file replacement races. + SOCKS5 username/password and HTTP Basic are cleartext on the local hop. Keep those listeners on loopback or enable the local HTTPS proxy. A non-loopback plaintext listener requires an explicit override and should still be protected diff --git a/internal/security/tls.go b/internal/security/tls.go index 92de579..7fa2c85 100644 --- a/internal/security/tls.go +++ b/internal/security/tls.go @@ -121,9 +121,10 @@ func CertPoolFromPEM(caPEM []byte) (*x509.CertPool, error) { return pool, nil } -// LoadCertPool reads PEM trust anchors from path. +// LoadCertPool reads PEM trust anchors from a regular file no larger than 4 MiB. +// Symlinks to regular files are supported. func LoadCertPool(path string) (*x509.CertPool, error) { - caPEM, err := os.ReadFile(path) + caPEM, err := readTLSMaterialFile(path, "CA file", false) if err != nil { return nil, fmt.Errorf("read CA file: %w", err) } @@ -135,60 +136,71 @@ func LoadCertPool(path string) (*x509.CertPool, error) { } // LoadKeyPair loads a PEM certificate and private key suitable for the TLS -// option types in this package. +// option types in this package. Both inputs must resolve to regular files no +// larger than 4 MiB; private keys require mode 0600 on Unix. func LoadKeyPair(certFile, keyFile string) (tls.Certificate, error) { - key, err := os.Open(keyFile) + keyPEM, err := readTLSMaterialFile(keyFile, "TLS private key", true) if err != nil { - return tls.Certificate{}, fmt.Errorf("open TLS private key: %w", err) + return tls.Certificate{}, err } - defer key.Close() - info, err := key.Stat() + certPEM, err := readTLSMaterialFile(certFile, "TLS certificate", false) if err != nil { - return tls.Certificate{}, fmt.Errorf("stat TLS private key: %w", err) + return tls.Certificate{}, err } - if !info.Mode().IsRegular() { - return tls.Certificate{}, errors.New("security: TLS private key is not a regular file") - } - if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { - return tls.Certificate{}, fmt.Errorf("security: TLS private key %q permissions are %04o; want 0600", keyFile, info.Mode().Perm()) - } - if info.Size() > maxTLSMaterialSize { - return tls.Certificate{}, fmt.Errorf("security: TLS private key exceeds %d bytes", maxTLSMaterialSize) + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return tls.Certificate{}, fmt.Errorf("load TLS key pair: %w", err) } - keyPEM, err := io.ReadAll(io.LimitReader(key, maxTLSMaterialSize+1)) + return cert, nil +} + +// readTLSMaterialFile accepts bounded regular files, including symlinks to +// them. The pre-open check prevents accidental FIFO/device inputs from blocking +// at Open. It is not a race-free guarantee for attacker-writable directories; +// deployment credentials and their parent directories must remain trusted. +func readTLSMaterialFile(path, description string, private bool) ([]byte, error) { + info, err := os.Stat(path) if err != nil { - return tls.Certificate{}, fmt.Errorf("read TLS private key: %w", err) + // Keep the public loader's existing open-error category for unavailable + // paths, even when preflight can detect that failure before Open. + return nil, fmt.Errorf("open %s: %w", description, err) } - if len(keyPEM) > maxTLSMaterialSize { - return tls.Certificate{}, fmt.Errorf("security: TLS private key exceeds %d bytes", maxTLSMaterialSize) + if err := validateTLSMaterialFile(info, path, description, private); err != nil { + return nil, err } - certFileHandle, err := os.Open(certFile) + file, err := os.Open(path) if err != nil { - return tls.Certificate{}, fmt.Errorf("open TLS certificate: %w", err) + return nil, fmt.Errorf("open %s: %w", description, err) } - defer certFileHandle.Close() - certInfo, err := certFileHandle.Stat() + defer file.Close() + info, err = file.Stat() if err != nil { - return tls.Certificate{}, fmt.Errorf("stat TLS certificate: %w", err) - } - if !certInfo.Mode().IsRegular() { - return tls.Certificate{}, errors.New("security: TLS certificate is not a regular file") + return nil, fmt.Errorf("stat %s: %w", description, err) } - if certInfo.Size() > maxTLSMaterialSize { - return tls.Certificate{}, fmt.Errorf("security: TLS certificate exceeds %d bytes", maxTLSMaterialSize) + if err := validateTLSMaterialFile(info, path, description, private); err != nil { + return nil, err } - certPEM, err := io.ReadAll(io.LimitReader(certFileHandle, maxTLSMaterialSize+1)) + contents, err := io.ReadAll(io.LimitReader(file, maxTLSMaterialSize+1)) if err != nil { - return tls.Certificate{}, fmt.Errorf("read TLS certificate: %w", err) + return nil, fmt.Errorf("read %s: %w", description, err) } - if len(certPEM) > maxTLSMaterialSize { - return tls.Certificate{}, fmt.Errorf("security: TLS certificate exceeds %d bytes", maxTLSMaterialSize) + if len(contents) > maxTLSMaterialSize { + return nil, fmt.Errorf("security: %s exceeds %d bytes", description, maxTLSMaterialSize) } - cert, err := tls.X509KeyPair(certPEM, keyPEM) - if err != nil { - return tls.Certificate{}, fmt.Errorf("load TLS key pair: %w", err) + return contents, nil +} + +func validateTLSMaterialFile(info os.FileInfo, path, description string, private bool) error { + if !info.Mode().IsRegular() { + return fmt.Errorf("security: %s is not a regular file", description) } - return cert, nil + if private && runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + return fmt.Errorf("security: %s %q permissions are %04o; want 0600", description, path, info.Mode().Perm()) + } + if info.Size() > maxTLSMaterialSize { + return fmt.Errorf("security: %s exceeds %d bytes", description, maxTLSMaterialSize) + } + return nil } func explicitCertPool(base *x509.CertPool, caPEM []byte) (*x509.CertPool, error) { diff --git a/internal/security/tls_fifo_test.go b/internal/security/tls_fifo_test.go new file mode 100644 index 0000000..a98e948 --- /dev/null +++ b/internal/security/tls_fifo_test.go @@ -0,0 +1,75 @@ +//go:build linux || darwin + +package security + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestTLSFileLoadersRejectFIFOWithoutBlocking(t *testing.T) { + if role := os.Getenv("AUTOCAR_TEST_TLS_FIFO_ROLE"); role != "" { + var err error + path := os.Getenv("AUTOCAR_TEST_TLS_FIFO_PATH") + switch role { + case "CA": + _, err = LoadCertPool(path) + case "certificate": + _, err = LoadKeyPair(path, os.Getenv("AUTOCAR_TEST_TLS_FIFO_KEY")) + case "private key": + _, err = LoadKeyPair(os.Getenv("AUTOCAR_TEST_TLS_FIFO_CERT"), path) + default: + t.Fatalf("unknown child role %q", role) + } + if err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("FIFO was not rejected as a non-regular file: %v", err) + } + return + } + + dir := t.TempDir() + certPath, keyPath := filepath.Join(dir, "valid.crt"), filepath.Join(dir, "valid.key") + if err := WriteSelfSignedCertificate(certPath, keyPath, CertificateOptions{Hosts: []string{"relay.invalid"}}); err != nil { + t.Fatal(err) + } + fifoPath := filepath.Join(dir, "unopened.fifo") + if err := syscall.Mkfifo(fifoPath, 0o600); err != nil { + t.Fatal(err) + } + linkPath := filepath.Join(dir, "fifo.link") + if err := os.Symlink(fifoPath, linkPath); err != nil { + t.Fatal(err) + } + for _, role := range []string{"CA", "certificate", "private key"} { + for _, path := range []string{fifoPath, linkPath} { + t.Run(role+"/"+filepath.Base(path), func(t *testing.T) { + // A regressed loader would block before it can inspect the FIFO. + // Keep it in a deadline-controlled child, never an unkillable test + // goroutine. No process writes to or streams data through the FIFO. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestTLSFileLoadersRejectFIFOWithoutBlocking$", "-test.count=1") + command.WaitDelay = time.Second + command.Env = append(os.Environ(), + "AUTOCAR_TEST_TLS_FIFO_ROLE="+role, + "AUTOCAR_TEST_TLS_FIFO_PATH="+path, + "AUTOCAR_TEST_TLS_FIFO_CERT="+certPath, + "AUTOCAR_TEST_TLS_FIFO_KEY="+keyPath, + ) + output, err := command.CombinedOutput() + if ctx.Err() != nil { + t.Fatalf("FIFO loader blocked past child deadline: %v", ctx.Err()) + } + if err != nil { + t.Fatalf("FIFO loader child failed: %v\n%s", err, output) + } + }) + } + } +} diff --git a/internal/security/tls_file_test.go b/internal/security/tls_file_test.go new file mode 100644 index 0000000..f1e902d --- /dev/null +++ b/internal/security/tls_file_test.go @@ -0,0 +1,129 @@ +package security + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestTLSFileLoadersEnforceSizeAndTypeBounds(t *testing.T) { + certPEM, keyPEM, err := GenerateSelfSignedCertificate(CertificateOptions{Hosts: []string{"relay.invalid"}}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + certPath, keyPath := filepath.Join(dir, "valid.crt"), filepath.Join(dir, "valid.key") + for path, contents := range map[string][]byte{certPath: certPEM, keyPath: keyPEM} { + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + } + for _, test := range []struct { + name string + contents []byte + load func(string) error + }{ + {"CA", certPEM, func(path string) error { _, err := LoadCertPool(path); return err }}, + {"certificate", certPEM, func(path string) error { _, err := LoadKeyPair(path, keyPath); return err }}, + {"private key", keyPEM, func(path string) error { _, err := LoadKeyPair(certPath, path); return err }}, + } { + t.Run(test.name, func(t *testing.T) { + if err := test.load(t.TempDir()); err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("directory accepted: %v", err) + } + missing := filepath.Join(t.TempDir(), "missing") + if err := test.load(missing); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing file lost its error category: %v", err) + } + for _, size := range []int{maxTLSMaterialSize, maxTLSMaterialSize + 1} { + path := filepath.Join(t.TempDir(), "bounded.pem") + contents := bytes.Repeat([]byte(" "), size) + copy(contents, test.contents) + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + err := test.load(path) + if size == maxTLSMaterialSize { + if err != nil { + t.Fatalf("valid material exactly at limit rejected: %v", err) + } + } else if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("oversized material accepted: %v", err) + } + } + }) + } +} + +func TestLoadCertPoolRejectsEmptyAndMalformedFiles(t *testing.T) { + for _, contents := range []string{"", "not a PEM certificate"} { + path := filepath.Join(t.TempDir(), "ca.pem") + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + if _, err := LoadCertPool(path); err == nil || !strings.Contains(err.Error(), "parse CA file") { + t.Fatalf("bad CA file lost its parse error: %v", err) + } + } +} + +func TestTLSFileLoadersPreservePublicAndPrivatePermissions(t *testing.T) { + certPEM, keyPEM, err := GenerateSelfSignedCertificate(CertificateOptions{Hosts: []string{"relay.invalid"}}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + certPath, keyPath := filepath.Join(dir, "public.crt"), filepath.Join(dir, "private.key") + if err := os.WriteFile(certPath, append(append([]byte(nil), certPEM...), certPEM...), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadCertPool(certPath); err != nil { + t.Fatalf("publicly readable CA bundle rejected: %v", err) + } + if _, err := LoadKeyPair(certPath, keyPath); err != nil { + t.Fatalf("public certificate/private key rejected: %v", err) + } + if runtime.GOOS == "windows" { + return // Windows deployments use ACLs, not Unix permission bits. + } + for _, mode := range []os.FileMode{0o644, 0o400} { + if err := os.Chmod(keyPath, mode); err != nil { + t.Fatal(err) + } + if _, err := LoadKeyPair(certPath, keyPath); err == nil || !strings.Contains(err.Error(), "permissions") { + t.Fatalf("private key mode %04o changed existing 0600-only policy: %v", mode, err) + } + } +} + +func TestTLSFileLoadersAllowRegularFileSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating symlinks can require Windows privileges") + } + certPEM, keyPEM, err := GenerateSelfSignedCertificate(CertificateOptions{Hosts: []string{"relay.invalid"}}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + for name, contents := range map[string][]byte{"cert.pem": certPEM, "key.pem": keyPEM} { + if err := os.WriteFile(filepath.Join(dir, name), contents, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(name, filepath.Join(dir, name+".link")); err != nil { + t.Fatal(err) + } + } + if _, err := LoadCertPool(filepath.Join(dir, "cert.pem.link")); err != nil { + t.Fatalf("symlink to regular CA rejected: %v", err) + } + if _, err := LoadKeyPair(filepath.Join(dir, "cert.pem.link"), filepath.Join(dir, "key.pem.link")); err != nil { + t.Fatalf("symlinks to regular key pair rejected: %v", err) + } +} diff --git a/scripts/docker-integration.sh b/scripts/docker-integration.sh index 0c2c7be..19c22ed 100755 --- a/scripts/docker-integration.sh +++ b/scripts/docker-integration.sh @@ -461,20 +461,25 @@ create_secret_volume "$invalid_client_volume" --cap-drop=ALL \ --security-opt no-new-privileges:true \ --mount "type=volume,src=${relay_volume},dst=/run/autocar" \ - "$integration_image_id" cert \ - --hosts=relay \ - --cert=/run/autocar/server.crt \ - --key=/run/autocar/server.key + "$integration_image_id" init \ + --server=relay:8443 \ + --out=/run/autocar/setup + +# Both checks run without network access and outside the configuration +# directory. They must load paths relative to each JSON file, without +# resolving the relay hostname, binding listeners, or changing credentials. "$docker_bin" run --rm \ --name "$token_container" \ --label "$integration_label" \ --network none \ + --workdir=/licenses \ --read-only \ --cap-drop=ALL \ --security-opt no-new-privileges:true \ - --mount "type=volume,src=${relay_volume},dst=/run/autocar" \ - "$integration_image_id" token \ - --out=/run/autocar/relay-token + --mount "type=volume,src=${relay_volume},dst=/run/autocar,readonly" \ + "$integration_image_id" server \ + --config=/run/autocar/setup/server/server.json \ + --check "$docker_bin" run --rm \ --name "$wrong_token_container" \ --label "$integration_label" \ @@ -498,19 +503,41 @@ create_secret_volume "$invalid_client_volume" --mount "type=volume,src=${valid_client_volume},dst=/run/valid-client" \ --mount "type=volume,src=${invalid_client_volume},dst=/run/invalid-client" \ "$alpine_image" /bin/sh -eu -c ' - cp /run/relay/server.crt /run/valid-client/server.crt - cp /run/relay/relay-token /run/valid-client/relay-token - cp /run/relay/server.crt /run/invalid-client/server.crt - chmod 0600 /run/valid-client/server.crt /run/valid-client/relay-token + for directory in /run/relay/setup /run/relay/setup/server /run/relay/setup/client; do + test "$(stat -c %a "$directory")" = 700 + done + cp /run/relay/setup/client/client.json /run/valid-client/client.json + cp /run/relay/setup/client/server.crt /run/valid-client/server.crt + cp /run/relay/setup/client/relay-token /run/valid-client/relay-token + cp /run/relay/setup/client/server.crt /run/invalid-client/server.crt + chmod 0600 /run/valid-client/client.json /run/valid-client/server.crt /run/valid-client/relay-token chmod 0600 /run/invalid-client/server.crt /run/invalid-client/wrong-token - test -r /run/relay/server.key - test ! -e /run/relay/wrong-token + test -r /run/relay/setup/server/server.key + test ! -e /run/relay/setup/server/wrong-token + test ! -e /run/relay/setup/client/server.key + test ! -e /run/relay/setup/client/client.key test ! -e /run/valid-client/server.key test ! -e /run/valid-client/wrong-token test ! -e /run/invalid-client/server.key test ! -e /run/invalid-client/relay-token ' +# Reuse the already removed check container name so cleanup retains the same +# exact, uniquely labeled resource scope. Only the exported client bundle is +# mounted: this also catches accidental dependencies on server private files. +"$docker_bin" run --rm \ + --name "$token_container" \ + --label "$integration_label" \ + --network none \ + --workdir=/licenses \ + --read-only \ + --cap-drop=ALL \ + --security-opt no-new-privileges:true \ + --mount "type=volume,src=${valid_client_volume},dst=/run/autocar,readonly" \ + "$integration_image_id" client \ + --config=/run/autocar/client.json \ + --check + integration_network_id=$("$docker_bin" network create --label "$integration_label" "$integration_network") network_label=$("$docker_bin" network inspect \ --format '{{ index .Labels "com.cppla.autocar.integration.run" }}' \ @@ -544,11 +571,7 @@ relay_container_id=$("$docker_bin" run -d \ --security-opt no-new-privileges:true \ --mount "type=volume,src=${relay_volume},dst=/run/autocar,readonly" \ "$integration_image_id" server \ - --listen=:8443 \ - --tcp-listen=:8443 \ - --cert=/run/autocar/server.crt \ - --key=/run/autocar/server.key \ - --token-file=/run/autocar/relay-token \ + --config=/run/autocar/setup/server/server.json \ --allow-private \ --max-streams=8 \ --max-connections=4 \ @@ -584,12 +607,10 @@ if doctor_output=$("$docker_bin" run --rm \ --security-opt no-new-privileges:true \ --mount "type=volume,src=${valid_client_volume},dst=/run/autocar,readonly" \ "$integration_image_id" doctor \ + --config=/run/autocar/client.json \ --transport=auto \ --server=relay:9443 \ --fallback-server=relay:8443 \ - --server-name=relay \ - --ca=/run/autocar/server.crt \ - --token-file=/run/autocar/relay-token \ --target=target:9000 \ --dial-timeout=1s \ --quic-attempt-timeout=750ms \ @@ -613,4 +634,4 @@ expect_auth_rejected tls relay:8443 assert_services_running integration_passed=1 -echo "docker integration passed: QUIC, TLS, auto fallback, doctor, token rejection, non-root read-only containers" +echo "docker integration passed: init, offline config checks, QUIC, TLS, auto fallback, doctor, token rejection, non-root read-only containers"