Skip to content

feat: allow mTLS auth for edge agents#2116

Draft
kmendell wants to merge 1 commit intomainfrom
feat/allow-mtls
Draft

feat: allow mTLS auth for edge agents#2116
kmendell wants to merge 1 commit intomainfrom
feat/allow-mtls

Conversation

@kmendell
Copy link
Member

@kmendell kmendell commented Mar 21, 2026

Checklist

  • This PR is not opened from my fork’s main branch

What This PR Implements

Fixes:

Changes Made

Testing Done

  • Development environment started: ./scripts/development/dev.sh start
  • Frontend verified at http://localhost:3000
  • Backend verified at http://localhost:3552
  • Manual testing completed (describe):
  • No linting errors (e.g., just lint all)
  • Backend tests pass: just test backend

AI Tool Used (if applicable)

AI Tool:
Assistance Level:
What AI helped with:
I reviewed and edited all AI-generated output:
I ran all required tests and manually verified changes:

Additional Context

Disclaimer Greptiles Reviews use AI, make sure to check over its work.

To better help train Greptile on our codebase, if the comment is useful and valid Like the comment, if its not helpful or invalid Dislike

To have Greptile Re-Review the changes, mention greptileai.

Greptile Summary

This PR adds mutual TLS (mTLS) authentication support for edge agents connecting to the Arcane manager. It introduces ECDSA P-384 certificate generation and validation, an auto-enrollment endpoint (/api/tunnel/mtls/enroll) that issues per-environment client certificates, and enforcement of client certificate presence on the WebSocket, poll, and gRPC tunnel endpoints when EDGE_MTLS_MODE=required is configured. A new arcane generate CLI subcommand provides tooling to manually generate mTLS and TLS asset bundles.

Key changes:

  • backend/pkg/libarcane/edge/tls.go — new file; CA and client cert lifecycle (generate, validate, validate key-pair match, auto-enroll from manager)
  • backend/pkg/libarcane/edge/server.goHandleMTLSEnroll endpoint; requireClientCertificateInternal guard added to WebSocket connect handler and gRPC stream interceptor
  • backend/pkg/libarcane/edge/poll_control.go — same requireClientCertificateInternal guard added to the poll handler
  • backend/internal/bootstrap/bootstrap.goprepareServerTLSInternal orchestrates CA auto-generation and manager-side validation in the correct order
  • backend/internal/services/environment_service.goGenerateEdgeDeploymentSnippets now emits mTLS-aware Docker run/compose snippets when auto-generated assets exist
  • cli/pkg/generate/certs.go — new CLI subcommands arcane generate mtls and arcane generate tls

Issues found:

  • The auto-enrollment path in EnsureAgentMTLSAssets does not verify that the manager URL is HTTPS before downloading the agent's private key, allowing key material to be transmitted in plaintext if misconfigured (P1 security)
  • After a successful enrollment the downloaded ca.crt path is never set in cfg.EdgeMTLSCAFile, which can cause TLS verification failures on subsequent connections when the manager uses a custom CA
  • Several unexported helper functions in cli/pkg/generate/certs.go are missing the required Internal suffix

Confidence Score: 3/5

  • The core mTLS enforcement logic is sound, but a P1 security issue — private key material can be transmitted over plain HTTP during auto-enrollment — should be fixed before merging.
  • The certificate generation, validation, key-pair verification, and enforcement paths are well-implemented and well-tested. However, EnsureAgentMTLSAssets transmits the agent's private key before validating that the connection is HTTPS, which is a meaningful security gap even if it requires a misconfigured deployment to trigger. Additionally, the downloaded CA cert is not wired back into the config, which can silently break TLS verification in custom-CA deployments.
  • backend/pkg/libarcane/edge/tls.go (auto-enrollment HTTPS guard and post-enrollment CA path), cli/pkg/generate/certs.go (naming convention violations)

Important Files Changed

Filename Overview
backend/pkg/libarcane/edge/tls.go New file implementing mTLS cert generation, validation, and auto-enrollment. Contains the P1 security issue where private key material can be downloaded over plain HTTP during auto-enrollment, and does not update cfg.EdgeMTLSCAFile after enrollment.
cli/pkg/generate/certs.go New CLI command file for generating mTLS and TLS bundles. Logic is sound but violates the project naming convention — most unexported helpers are missing the required Internal suffix.
backend/pkg/libarcane/edge/server.go Added mTLS client certificate enforcement at the handler and gRPC interceptor level, plus the new HandleMTLSEnroll endpoint for auto-enrollment. Defense is correctly done at the application layer to allow a shared HTTP/gRPC/WS listener.
backend/internal/bootstrap/edge_bootstrap.go Wires the TunnelServer config fields for mTLS and registers the new /tunnel/mtls/enroll route. Straightforward and correct.
backend/internal/services/environment_service.go Adds GenerateEdgeDeploymentSnippets and buildMTLSDeploymentSnippetInternal for producing Docker run/compose snippets with mTLS. The generated snippets rely on auto-enrollment at runtime, which is consistent with the design.
backend/pkg/libarcane/edge/tls_test.go Tests for mTLS asset generation and enrollment. All tests use plain HTTP for the enrollment server, which means the TLS-over-HTTP security concern cannot be caught by these tests.
backend/pkg/libarcane/edge/client_transport_grpc.go Adds mTLS support to the gRPC transport by reusing buildManagerClientTLSConfigInternal. Clean implementation with correct handling of the TLS/non-TLS decision.
backend/internal/bootstrap/bootstrap.go Adds prepareServerTLSInternal with correct ordering: PrepareManagerMTLSAssets runs first (auto-generates CA if needed), then ValidateManagerMTLSConfig validates the final state. The EDGE_MTLS_MODE requires TLS_ENABLED guard is correct.

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: backend/pkg/libarcane/edge/tls.go
Line: 201-253

Comment:
**Private key material transmitted over plain HTTP during auto-enrollment**

`EnsureAgentMTLSAssets` does not validate that `managerBaseURL` uses HTTPS before making the enrollment request. If an agent is started with `EDGE_MTLS_MODE=required` and an `http://` manager URL, the `buildManagerClientTLSConfigInternal` call returns `nil` (no TLS config), so the HTTP client sends the request over plaintext. The enrollment response includes the agent's EC private key (`agent.key`), which is then transmitted unencrypted.

`ValidateAgentMTLSConfig` — called by the caller *after* this function returns — does check that the URL is HTTPS, but by then the private key has already been received over the wire.

Add an early guard before the network call:

```go
if !managerUsesTLSInternal(cfg) {
    return fmt.Errorf("EDGE_MTLS_MODE requires MANAGER_API_URL to use https for certificate enrollment")
}
```

**Rule Used:** # Golang Pro

Senior Go developer with deep expert... ([source](https://app.greptile.com/review/custom-context?memory=214b40a8-9695-4738-986d-5949b5d65ff1))

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: cli/pkg/generate/certs.go
Line: 63

Comment:
**Unexported functions missing `Internal` suffix**

Per the project convention, all unexported (package-private) functions must have the `Internal` suffix. The following functions in `cli/pkg/generate/certs.go` are unexported but do not follow this convention — note that `randomSerialInternal` on line 253 correctly uses the suffix:

- `generateMTLSOutput` (line 63)
- `generateTLSOutput` (line 88)
- `generateEdgeMTLSBundle` (line 120)
- `generateServerTLSBundle` (line 187)
- `newCertificateTemplate` (line 238)
- `writeCertificateBundle` (line 262)
- `writePEMFile` (line 276)

Each should be renamed with the `Internal` suffix, e.g. `generateMTLSOutputInternal`, `writePEMFileInternal`, etc.

**Rule Used:** What: All unexported functions must have the "Inte... ([source](https://app.greptile.com/review/custom-context?memory=306fc233-4d2f-4ac4-bdf7-8059588e8a43))

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: backend/pkg/libarcane/edge/tls.go
Line: 237-253

Comment:
**Downloaded CA cert path not reflected in config after auto-enrollment**

After writing the enrolled files to `assetsDir`, the code sets `cfg.EdgeMTLSCertFile` and `cfg.EdgeMTLSKeyFile` but does not update `cfg.EdgeMTLSCAFile`. The enrollment response always includes `ca.crt` (the manager's mTLS CA), which is written to disk at `filepath.Join(assetsDir, "ca.crt")`.

If the manager's TLS server certificate is signed by this same CA (e.g. when using `arcane generate mtls` to produce a combined bundle), subsequent connections from the agent will use only the system cert pool (since `cfg.EdgeMTLSCAFile` is empty) and will fail TLS verification against the manager.

Consider setting the CA path after writing the files:
```go
cfg.EdgeMTLSCertFile = certPath
cfg.EdgeMTLSKeyFile = keyPath

caPath := filepath.Join(assetsDir, generatedMTLSCACertFileName)
if fileExistsInternal(caPath) && cfg.EdgeMTLSCAFile == "" {
    cfg.EdgeMTLSCAFile = caPath
}
return nil
```
Note: only do this if the deployment intentionally uses the mTLS CA as the TLS server CA; if the server uses a separately signed certificate, leave this empty and require explicit configuration.

**Rule Used:** # Golang Pro

Senior Go developer with deep expert... ([source](https://app.greptile.com/review/custom-context?memory=214b40a8-9695-4738-986d-5949b5d65ff1))

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "feat: allow mTLS aut..."

Greptile also left 3 inline comments on this PR.

Context used:

  • Rule used - What: All unexported functions must have the "Inte... (source)
  • Rule used - # Golang Pro

Senior Go developer with deep expert... (source)

Copy link
Member Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@getarcaneappbot
Copy link
Contributor

getarcaneappbot commented Mar 21, 2026

Container images for this PR have been built successfully!

  • Manager: ghcr.io/getarcaneapp/arcane:pr-2116
  • Agent: ghcr.io/getarcaneapp/arcane-headless:pr-2116

Built from commit 4637504

@kmendell kmendell force-pushed the feat/allow-mtls branch 2 times, most recently from 523b869 to c9faa5a Compare March 22, 2026 01:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants