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
2 changes: 1 addition & 1 deletion BUILD.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ adapted files to AGPL-3.0.
| R2 | Client-side mTLS termination + metadata sanitization |
| R3 | Embedded mode (in-process with `helm`) |
| R4 | Remote reverse-tunnel mode (`helm` dials out) + reconnect/keepalive |
| R5 | Relay enrollment (bootstrap token, Fleet-CA relay cert) |
| R5 | Relay enrollment — `gen-csr` CSR-over-SSH, Fleet-CA relay cert (decision 14, no token) |
| R6 | Static-binary packaging + deploy docs |

## Non-negotiables
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,17 @@ Go · transparent gRPC proxy · reverse-tunnel transport (multiplexed) · mTLS.
- [`relay/`](relay/) — the embeddable relay: the transparent proxy, the public
mTLS listener, and the in-memory `Pipe` for embedded mode.
- [`tunnel/`](tunnel/) — the reverse-tunnel transport `helm` dials out over.
- [`cmd/beacon`](cmd/beacon/) — the remote-relay binary (`beacon run`).
- [`cmd/beacon`](cmd/beacon/) — the relay binary: `gen-csr` (SSH enrolment)
and `run` (the remote relay).

`helm` embeds a relay in-process by importing the `relay` package — see
[docs/HELM-INTEGRATION.md](docs/HELM-INTEGRATION.md).

## Status

🚧 Pre-alpha. The transparent proxy and both transports (embedded + remote)
are built; relay enrollment and packaging are next. See [BUILD.md](BUILD.md).
🚧 Pre-alpha. The transparent proxy, both transports (embedded + remote), and
SSH relay enrolment (`gen-csr`) are built; static-binary packaging and deploy
docs are next. See [BUILD.md](BUILD.md).

## License

Expand Down
22 changes: 22 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,25 @@ func TestLoadMaterialComplete(t *testing.T) {
t.Errorf("relayCert = %q, want %q", m.relayCert, "pem-"+fileRelayCrt)
}
}

// TestGenCSRCommand confirms `beacon gen-csr` writes the relay key into
// the config dir and prints only a CSR PEM to stdout — helm captures
// that stdout verbatim over SSH.
func TestGenCSRCommand(t *testing.T) {
dir := t.TempDir()
root := newRootCmd()
var out, errOut bytes.Buffer
root.SetOut(&out)
root.SetErr(&errOut)
root.SetArgs([]string{"gen-csr", "--config-dir", dir})

if err := root.Execute(); err != nil {
t.Fatalf("execute gen-csr: %v", err)
}
if got := out.String(); !strings.HasPrefix(got, "-----BEGIN CERTIFICATE REQUEST-----") {
t.Errorf("stdout is not a CSR PEM: %q", got)
}
if _, err := os.Stat(filepath.Join(dir, fileRelayKey)); err != nil {
t.Errorf("relay key not written: %v", err)
}
}
53 changes: 53 additions & 0 deletions internal/cli/gencsr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 The PharosVPN Authors

package cli

import (
"fmt"
"path/filepath"

"github.com/PharosVPN/beacon/internal/pki"
"github.com/spf13/cobra"
)

// newGenCSRCmd generates the relay's mTLS keypair on the host and
// prints a PEM-encoded certificate signing request to stdout.
//
// This is the first step of SSH enrolment (DESIGN §5, decision 14):
// helm runs `beacon gen-csr` over SSH, captures the CSR from stdout,
// signs it with the Fleet CA, and pushes relay.crt, fleet-ca.crt and
// device-ca.crt back. The relay's private key is written to relay.key
// and never leaves the host.
//
// Re-running gen-csr is idempotent — an existing key is reused.
func newGenCSRCmd() *cobra.Command {
var configDir string

cmd := &cobra.Command{
Use: "gen-csr",
Short: "Generate the relay keypair and print a CSR for helm to sign",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
keyPath := filepath.Join(configDir, fileRelayKey)
res, err := pki.GenerateCSR(keyPath)
if err != nil {
return err
}

// Diagnostics go to stderr; stdout carries only the CSR so
// helm can capture it cleanly over SSH. A failed diagnostic
// write must not fail gen-csr itself.
if res.KeyGenerated {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "beacon: generated relay key at %s\n", keyPath)
} else {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "beacon: reusing existing relay key at %s\n", keyPath)
}
_, err = cmd.OutOrStdout().Write(res.CSRPEM)
return err
},
}
cmd.Flags().StringVar(&configDir, "config-dir", defaultConfigDir,
"directory the relay keypair is written to")
return cmd
}
10 changes: 6 additions & 4 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 The PharosVPN Authors

// Package cli wires up the beacon command-line interface — the `run`
// command that operates a remote relay, and `version`. The embedded
// relay is not a CLI command: helm imports github.com/PharosVPN/beacon/relay
// and runs it in-process (see docs/HELM-INTEGRATION.md).
// Package cli wires up the beacon command-line interface — `gen-csr`
// and `run`, which form the helm↔beacon relay-enrolment contract, plus
// `version`. The embedded relay is not a CLI command: helm imports
// github.com/PharosVPN/beacon/relay and runs it in-process (see
// docs/HELM-INTEGRATION.md).
package cli

import (
Expand Down Expand Up @@ -34,6 +35,7 @@ func newRootCmd() *cobra.Command {
SilenceErrors: true,
}
root.AddCommand(
newGenCSRCmd(),
newRunCmd(),
newVersionCmd(),
)
Expand Down
138 changes: 138 additions & 0 deletions internal/pki/csr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 The PharosVPN Authors

// Package pki handles beacon's relay-side certificate material: it
// generates the relay's mTLS keypair on the host and emits a
// certificate signing request.
//
// The relay's private key is generated here and never leaves the host.
// helm signs the CSR with the Fleet CA and pushes back the relay
// certificate and the two trust anchors over SSH (DESIGN §5, decision
// 14 — CSR-over-SSH, no bootstrap token).
//
// The CSR is deliberately plain: it carries only the public key. helm
// is the sole authority on the relay's identity and overrides the
// subject and SANs when it signs — Subject O="PharosVPN Relay" (the
// pinned delegation marker, which a relay host must not self-assert),
// dual ServerAuth+ClientAuth EKU, and the public-endpoint DNS SAN. See
// helm/BUILD.md, "Relay enrollment contract".
package pki

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
)

// keyFileMode is restrictive: the relay private key is readable only by
// its owner. It never leaves the host.
const keyFileMode = 0o600

// csrSubject is a human-readable label only. helm ignores the CSR
// subject entirely and assigns the real identity at signing time —
// crucially Organization, which carries the delegation marker and must
// not be self-asserted by the relay host.
var csrSubject = pkix.Name{CommonName: "pharos-beacon-relay"}

// CSRResult is the outcome of GenerateCSR.
type CSRResult struct {
// CSRPEM is the PEM-encoded PKCS#10 certificate request, for helm
// to sign.
CSRPEM []byte
// KeyGenerated reports whether a new private key was created. It is
// false when an existing key at keyPath was reused.
KeyGenerated bool
}

// GenerateCSR ensures a relay private key exists at keyPath and returns
// a certificate signing request built from it.
//
// If keyPath already holds a key it is reused, making `beacon gen-csr`
// idempotent: re-running it after a failed enrolment emits a fresh CSR
// for the same key rather than orphaning the old one. The parent
// directory is created if missing.
func GenerateCSR(keyPath string) (CSRResult, error) {
key, generated, err := loadOrCreateKey(keyPath)
if err != nil {
return CSRResult{}, err
}

csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
Subject: csrSubject,
SignatureAlgorithm: x509.ECDSAWithSHA256,
}, key)
if err != nil {
return CSRResult{}, fmt.Errorf("pki: create CSR: %w", err)
}
return CSRResult{
CSRPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}),
KeyGenerated: generated,
}, nil
}

// loadOrCreateKey returns the relay private key at keyPath, generating
// and persisting a new ECDSA P-256 key if none exists.
func loadOrCreateKey(keyPath string) (*ecdsa.PrivateKey, bool, error) {
switch existing, err := os.ReadFile(keyPath); {
case err == nil:
key, err := parseECKey(existing)
if err != nil {
return nil, false, fmt.Errorf("pki: existing key %s: %w", keyPath, err)
}
return key, false, nil
case !errors.Is(err, os.ErrNotExist):
return nil, false, fmt.Errorf("pki: read %s: %w", keyPath, err)
}

key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, false, fmt.Errorf("pki: generate key: %w", err)
}
if err := writeKey(keyPath, key); err != nil {
return nil, false, err
}
return key, true, nil
}

// writeKey persists key as a PKCS#8 PEM file with owner-only
// permissions.
func writeKey(keyPath string, key *ecdsa.PrivateKey) error {
if dir := filepath.Dir(keyPath); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("pki: create %s: %w", dir, err)
}
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return fmt.Errorf("pki: marshal key: %w", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
if err := os.WriteFile(keyPath, pemBytes, keyFileMode); err != nil {
return fmt.Errorf("pki: write %s: %w", keyPath, err)
}
return nil
}

// parseECKey decodes a PKCS#8 PEM-encoded ECDSA private key.
func parseECKey(pemBytes []byte) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("not a PEM block")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
key, ok := parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("not an ECDSA key (%T)", parsed)
}
return key, nil
}
Loading
Loading