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
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@ jobs:
- name: Vet
run: go vet ./...

# Pinned, not @latest. staticcheck v0.8.0 requires Go >= 1.26, which
# this job cannot satisfy under GOTOOLCHAIN=local, so @latest breaks the
# build the moment upstream releases ahead of our Go matrix. Bump this
# deliberately alongside go-version.
- name: Staticcheck
run: |
go install honnef.co/go/tools/cmd/staticcheck@latest
go install honnef.co/go/tools/cmd/staticcheck@v0.7.0
staticcheck ./...

- name: Test
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,16 @@ phoenix exec --env API_KEY=phoenix://myapp/api-key -- env | grep API_KEY
```

> The default server config listens on `127.0.0.1:9090` over plain HTTP.
> This is safe for local-only use. For LAN or production deployment,
> enable TLS — see [LAN Deployment](docs/lan-deployment.md).
> That is the supported default — loopback traffic never touches the network.
> The moment other machines need access, enable `"tls": {"enabled": true}`
> (one line; the `--init` certificate is used automatically) — see
> [LAN Deployment](docs/lan-deployment.md). The server warns loudly at
> startup if it is ever bound to a non-loopback address without TLS.
>
> **Deployment envelope:** Phoenix is LAN-scoped by design. A single machine
> or a local network you operate is the supported territory; exposing Phoenix
> to the internet (port forwarding, public ingress) is out of scope and
> unsupported. See [Threat Model](docs/threat-model.md).

For the full first-run walkthrough, see [Getting Started](docs/getting-started.md).

Expand Down
41 changes: 41 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
# Release Notes

## v0.17.0 (2026-08-20)

### Secure Transport: LAN-Scoped by Design, Loud When It Matters

Phoenix is **LAN-scoped by design**. Use outside a LAN — internet/WAN
exposure, public ingress, hostile-network operation — is out of scope and
unsupported. Being in scope is not the same as being trusted: a LAN
includes containers on shared bridges, IoT devices, and anything that has
compromised one host, so TLS matters whenever a request crosses a wire.

Plaintext on loopback remains the default and is unchanged. Loopback
traffic never reaches a network interface, so TLS adds nothing there.

- **New first-class `tls:` config block**, independent of `mtls:`. TLS
serving previously existed only inside the mTLS block, so operators had
to enable client-certificate authentication just to obtain HTTPS.
`"tls": {"enabled": true}` now works on its own, defaulting to the
server leaf certificate that `--init` already generates. `mtls:` is now
purely the client-certificate layer.
- **Loud warning on insecure transport.** Binding a non-loopback address
without TLS previously produced no signal at all — a one-line config
change silently moved bearer tokens and secret values onto the wire.
The server now prints a prominent startup banner naming exactly what is
exposed and the one-line fix. It warns; it never refuses to start.
- **TLS for the MCP HTTP transport** — `phoenix mcp-server --http` gains
`--tls-cert`/`--tls-key` (or `PHOENIX_MCP_TLS_CERT`/`PHOENIX_MCP_TLS_KEY`),
and carries the same warning on plaintext non-loopback binds.
- **Client-side warnings** in the CLI and Go SDK for non-loopback
`http://` server URLs. Warn and proceed; never fail.
- **Docs**: `docs/threat-model.md` gains a deployment-envelope section and
its mitigations were re-audited against that envelope;
`docs/lan-deployment.md` is rewritten onto the `tls:` block with mTLS as
a separate optional step. No silent non-loopback `http://` examples
remain anywhere in the docs.
- **New e2e scenario 19** covering transport behavior.

**Upgrading:** no action required. Existing configs behave identically,
including `auth.mtls.enabled: true`, which still implies TLS. If you bind
a non-loopback address without TLS you will now see a startup warning —
that is the point, and the fix is one line.

## v0.16.0 (2026-08-16)

### Hermes Caller Metadata Audit Capture
Expand Down
18 changes: 15 additions & 3 deletions cmd/phoenix-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"github.com/phoenixsec/phoenix/internal/session"
"github.com/phoenixsec/phoenix/internal/store"
"github.com/phoenixsec/phoenix/internal/token"
"github.com/phoenixsec/phoenix/internal/transport"
"github.com/phoenixsec/phoenix/internal/version"
)

Expand Down Expand Up @@ -357,6 +358,9 @@ func main() {
}
log.Printf(" mTLS: enabled (require=%v)", cfg.Auth.MTLS.Require)
log.Printf(" Bearer: %v", cfg.Auth.Bearer.Enabled)
} else if cfg.TLSEnabled() {
// TLS without mTLS: encrypt the pipe, no client certificates.
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS13}
}

log.Printf("Phoenix server starting on %s", cfg.Server.Listen)
Expand All @@ -373,13 +377,21 @@ func main() {
IdleTimeout: 60 * time.Second,
}

if tlsCfg != nil {
if cfg.TLSEnabled() {
// Use the dedicated server leaf cert for TLS identity (NOT the CA cert)
log.Printf(" TLS: enabled (server cert: %s)", cfg.Auth.MTLS.ServerCert)
if err := httpSrv.ListenAndServeTLS(cfg.Auth.MTLS.ServerCert, cfg.Auth.MTLS.ServerKey); err != nil {
serverCert, serverKey := cfg.TLSCertKey()
log.Printf(" TLS: enabled (server cert: %s)", serverCert)
if err := httpSrv.ListenAndServeTLS(serverCert, serverKey); err != nil {
log.Fatalf("server error: %v", err)
}
} else {
log.Printf(" TLS: disabled (plaintext HTTP)")
if !transport.IsLoopbackListen(cfg.Server.Listen) {
// Loud, never fatal: loopback plaintext is the supported default,
// but a non-loopback bind without TLS puts bearer tokens and
// secret values on the wire in cleartext.
log.Printf("\n%s", transport.ServerPlaintextWarning(cfg.Server.Listen, cfg.Dashboard.Enabled))
}
if err := httpSrv.ListenAndServe(); err != nil {
log.Fatalf("server error: %v", err)
}
Expand Down
34 changes: 31 additions & 3 deletions cmd/phoenix/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import (
"github.com/phoenixsec/phoenix/internal/crypto"
"github.com/phoenixsec/phoenix/internal/policy"
"github.com/phoenixsec/phoenix/internal/store"
"github.com/phoenixsec/phoenix/internal/transport"
"github.com/phoenixsec/phoenix/internal/version"
)

Expand Down Expand Up @@ -130,6 +131,18 @@ func main() {
cmd := os.Args[1]
args := os.Args[2:]

// Warn (never fail) when the configured server is plaintext HTTP on a
// non-loopback address: bearer tokens and secret values would cross the
// network unencrypted. Loopback plaintext is the supported default.
switch cmd {
case "version", "--version", "-V", "help", "--help", "-h":
// No server contact.
default:
if w := transport.ClientPlaintextWarning(serverURL); w != "" {
fmt.Fprintln(os.Stderr, w)
}
}

var err error
switch cmd {
case "get":
Expand Down Expand Up @@ -273,6 +286,8 @@ func main() {
case "mcp-server":
httpAddr := ""
mcpToken := os.Getenv("PHOENIX_MCP_TOKEN")
mcpTLSCert := os.Getenv("PHOENIX_MCP_TLS_CERT")
mcpTLSKey := os.Getenv("PHOENIX_MCP_TLS_KEY")
for i := 0; i < len(args); i++ {
switch {
case args[i] == "--http" && i+1 < len(args):
Expand All @@ -285,10 +300,20 @@ func main() {
i++
case strings.HasPrefix(args[i], "--mcp-token="):
mcpToken = strings.TrimPrefix(args[i], "--mcp-token=")
case args[i] == "--tls-cert" && i+1 < len(args):
mcpTLSCert = args[i+1]
i++
case strings.HasPrefix(args[i], "--tls-cert="):
mcpTLSCert = strings.TrimPrefix(args[i], "--tls-cert=")
case args[i] == "--tls-key" && i+1 < len(args):
mcpTLSKey = args[i+1]
i++
case strings.HasPrefix(args[i], "--tls-key="):
mcpTLSKey = strings.TrimPrefix(args[i], "--tls-key=")
}
}
if httpAddr != "" {
err = cmdMCPHTTP(httpAddr, mcpToken)
err = cmdMCPHTTP(httpAddr, mcpToken, mcpTLSCert, mcpTLSKey)
} else {
err = cmdMCP(args)
}
Expand Down Expand Up @@ -367,7 +392,8 @@ Usage:
phoenix sessions revoke <session-id> Revoke a session
phoenix approve <approval-id> Approve a step-up session request
phoenix mcp-server Run MCP server (stdio JSON-RPC)
phoenix mcp-server --http :8080 Run MCP server (Streamable HTTP)
phoenix mcp-server --http 127.0.0.1:8080 Run MCP server (Streamable HTTP)
phoenix mcp-server --http <addr> --tls-cert <crt> --tls-key <key> Serve MCP over HTTPS
phoenix init <dir> Initialize data directory

Environment:
Expand All @@ -380,7 +406,9 @@ Environment:
PHOENIX_POLICY Path to attestation policy file (JSON)
PHOENIX_TOOL Tool/skill name for attestation (X-Phoenix-Tool header)
PHOENIX_ROLE Role name for auto-mint session identity
PHOENIX_MCP_TOKEN Bearer token for MCP HTTP client auth (--http mode)`)
PHOENIX_MCP_TOKEN Bearer token for MCP HTTP client auth (--http mode)
PHOENIX_MCP_TLS_CERT TLS server certificate for MCP HTTP mode (--tls-cert)
PHOENIX_MCP_TLS_KEY TLS server key for MCP HTTP mode (--tls-key)`)
}

// requireAuth checks that at least one auth method is configured
Expand Down
38 changes: 34 additions & 4 deletions cmd/phoenix/mcp_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,23 @@
//
// Usage:
//
// phoenix mcp-server --http :8080 --mcp-token <token>
// phoenix mcp-server --http 127.0.0.1:8080 --mcp-token <token>
//
// Or via environment:
//
// PHOENIX_MCP_TOKEN=<token> phoenix mcp-server --http :8080
// PHOENIX_MCP_TOKEN=<token> phoenix mcp-server --http 127.0.0.1:8080
//
// To serve MCP clients on other hosts, enable TLS:
//
// phoenix mcp-server --http 0.0.0.0:8080 --mcp-token <token> \
// --tls-cert /data/phoenix/server.crt --tls-key /data/phoenix/server.key
package main

import (
"bytes"
cryptorand "crypto/rand"
"crypto/subtle"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
Expand All @@ -26,6 +32,8 @@ import (
"strings"
"sync"
"time"

"github.com/phoenixsec/phoenix/internal/transport"
)

type mcpSession struct {
Expand Down Expand Up @@ -184,13 +192,20 @@ func (s *mcpHTTPServer) validSession(id string) bool {
}

// cmdMCPHTTP starts the MCP server in Streamable HTTP mode.
func cmdMCPHTTP(addr string, mcpToken string) error {
// tlsCert/tlsKey enable HTTPS serving (the Phoenix --init server leaf works:
// <data-dir>/server.crt and <data-dir>/server.key). Without TLS on a
// non-loopback address, a loud warning is emitted — but the server still
// starts: plaintext loopback is the supported default.
func cmdMCPHTTP(addr, mcpToken, tlsCert, tlsKey string) error {
if err := requireAuth(); err != nil {
return err
}
if mcpToken == "" {
return fmt.Errorf("MCP HTTP mode requires --mcp-token or PHOENIX_MCP_TOKEN")
}
if (tlsCert == "") != (tlsKey == "") {
return fmt.Errorf("--tls-cert and --tls-key must be set together")
}

logger := log.New(os.Stderr, "phoenix-mcp-http: ", log.LstdFlags)

Expand All @@ -204,6 +219,21 @@ func cmdMCPHTTP(addr string, mcpToken string) error {
mux := http.NewServeMux()
mux.Handle("/mcp", srv)

httpSrv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}

if tlsCert != "" {
httpSrv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS13}
logger.Printf("listening on %s (TLS)", addr)
return httpSrv.ListenAndServeTLS(tlsCert, tlsKey)
}

if !transport.IsLoopbackListen(addr) {
logger.Printf("\n%s", transport.MCPPlaintextWarning(addr))
}
logger.Printf("listening on %s", addr)
return http.ListenAndServe(addr, mux)
return httpSrv.ListenAndServe()
}
3 changes: 3 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"server": {
"listen": "127.0.0.1:9090"
},
"tls": {
"enabled": false
},
"store": {
"backend": "file",
"path": "/data/phoenix/store.json",
Expand Down
2 changes: 1 addition & 1 deletion docs/admin-token-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ phoenix agent create app-deployer -t "deploy-token" --acl "myapp/**:read,write"
If mTLS is enabled (required for `phoenix cert issue`):

```bash
export PHOENIX_SERVER="https://phoenix.home:9090"
export PHOENIX_SERVER="https://phoenix:9090"
export PHOENIX_CA_CERT="/data/phoenix/ca.crt"
phoenix cert issue app-runtime -o /etc/phoenix/certs
```
Expand Down
11 changes: 10 additions & 1 deletion docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ Phoenix supports bearer tokens, mTLS client certificates, and sealed-response ke
pairs. These can be combined — mTLS for identity, bearer for bootstrap, sealed keys
for context-safe delivery.

**Transport is separate from authentication.** TLS (HTTPS) comes from the
server's `tls` config block and does not require mTLS or client certificates —
bearer tokens work over TLS unchanged. mTLS is an authentication method layered
on top of TLS. Any auth method on this page can run over either transport;
whenever requests cross a wire (any non-loopback listener), enable the `tls`
block so tokens and secret values are encrypted in transit. See
[Configuration](configuration.md#transport-security) and
[LAN Deployment](lan-deployment.md).

## Bearer tokens

Create an agent with scoped permissions:
Expand Down Expand Up @@ -47,7 +56,7 @@ phoenix cert issue deployer -o /etc/phoenix/certs/
This writes `deployer.crt`, `deployer.key`, and `ca.crt`.

```bash
export PHOENIX_SERVER="https://phoenix.home:9090"
export PHOENIX_SERVER="https://192.168.1.10:9090"
export PHOENIX_CA_CERT="/etc/phoenix/certs/ca.crt"
export PHOENIX_CLIENT_CERT="/etc/phoenix/certs/deployer.crt"
export PHOENIX_CLIENT_KEY="/etc/phoenix/certs/deployer.key"
Expand Down
Loading