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
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,22 @@ BoardingPass provides a minimal service that runs on the headless device. You co

```sh
VERSION=$(curl -s https://api.github.com/repos/fzdarsky/boardingpass/releases/latest | jq -r .tag_name)
sudo dnf install "https://github.com/fzdarsky/boardingpass/releases/download/${VERSION}/boardingpass-${VERSION#v}-1.$(uname -m).rpm"
sudo dnf install -y "https://github.com/fzdarsky/boardingpass/releases/download/${VERSION}/boardingpass-${VERSION#v}-1.$(uname -m).rpm"
```

2. If you want to provision through a WiFi or Bluetooth transport, you need to install additional packages:

```sh
# For WiFi AP transport:
sudo dnf install hostapd dnsmasq
sudo dnf install -y hostapd dnsmasq

# For Bluetooth/BLE transport:
sudo dnf bluez
sudo dnf install -y bluez

# For USB transport:
sudo dnf install -y epel-release
sudo dnf install -y libimobiledevice
# You also need a very recent version of usbmuxd, which is not available on RHEL9

3. Start the service:

Expand Down
8 changes: 4 additions & 4 deletions build/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
# Install to: /etc/boardingpass/config.yaml

service:
inactivity_timeout: "10m" # Max idle time before service self-terminates (Go duration: "5m", "1h")
session_ttl: "30m" # Authenticated session lifetime (min: 5m)
inactivity_timeout: "60m" # Max idle time before service self-terminates (Go duration: "5m", "1h")
session_ttl: "60m" # Authenticated session lifetime (min: 5m)
sentinel_file: "/etc/boardingpass/issued" # Created on provisioning completion; prevents service restart
port: 9455 # HTTPS listen port (shared by all transports)
tls_cert: "/var/lib/boardingpass/tls/server.crt" # Path to TLS certificate (auto-generated if missing)
Expand Down Expand Up @@ -122,12 +122,12 @@ commands:
- id: "reload-connection"
path: "/usr/lib/boardingpass/scripts/reload-connection.sh"
args: []
max_params: 1
max_params: 2

- id: "connectivity-test"
path: "/usr/lib/boardingpass/scripts/connectivity-test.sh"
args: []
max_params: 2
max_params: 3

- id: "enroll-insights"
path: "/usr/lib/boardingpass/scripts/enroll-insights.sh"
Expand Down
24 changes: 19 additions & 5 deletions build/scripts/connectivity-test.sh
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
#!/bin/bash
# connectivity-test.sh - Test network connectivity and output JSON results
# Usage: connectivity-test.sh -- <interface> [gateway]
# Usage: connectivity-test.sh -- <interface> [gateway] [expected-ip]
# If gateway is omitted, the default gateway from the routing table is used.
# If expected-ip is provided, verifies that specific IP is assigned (not just any IP).
# Called by BoardingPass via the command allow-list.
set -euo pipefail
[ "${1:-}" = "--" ] && shift

if [ $# -lt 1 ]; then
echo "Usage: connectivity-test.sh <interface> [gateway]" >&2
echo "Usage: connectivity-test.sh <interface> [gateway] [expected-ip]" >&2
exit 1
fi

IFACE="$1"
GATEWAY="${2:-}"
EXPECTED_IP="${3:-}"

# Validate interface name
if ! [[ "$IFACE" =~ ^[a-zA-Z0-9._-]+$ ]]; then
Expand All @@ -37,16 +39,28 @@ if [ -n "$GATEWAY" ] && ! [[ "$GATEWAY" =~ ^[0-9a-fA-F.:]+$ ]]; then
exit 1
fi

# Validate expected IP if provided (IPv4 or IPv6)
if [ -n "$EXPECTED_IP" ] && ! [[ "$EXPECTED_IP" =~ ^[0-9a-fA-F.:]+$ ]]; then
echo "Error: invalid expected IP address '$EXPECTED_IP'" >&2
exit 1
fi

# 1. Check link/carrier (is a cable plugged in?)
LINK_UP=false
if [ "$(cat /sys/class/net/"$IFACE"/carrier 2>/dev/null || echo 0)" = "1" ]; then
LINK_UP=true
fi

# 2. Check interface has an IP address
# 2. Check interface has the expected IP address (or any IP if not specified)
IP_ASSIGNED=false
if ip -j addr show "$IFACE" 2>/dev/null | grep -q '"local"'; then
IP_ASSIGNED=true
if [ -n "$EXPECTED_IP" ]; then
if ip -j addr show "$IFACE" 2>/dev/null | grep -q "\"local\":\"$EXPECTED_IP\""; then
IP_ASSIGNED=true
fi
else
if ip -j addr show "$IFACE" 2>/dev/null | grep -q '"local"'; then
IP_ASSIGNED=true
fi
fi

# 3. Ping gateway via the provisioning interface (skip if no gateway found)
Expand Down
52 changes: 41 additions & 11 deletions build/scripts/reload-connection.sh
Original file line number Diff line number Diff line change
@@ -1,30 +1,60 @@
#!/bin/bash
# reload-connection.sh - Reload a NetworkManager connection profile from disk
# Usage: reload-connection.sh -- <connection-name>
# reload-connection.sh - Reload and optionally activate a NetworkManager connection
# Usage: reload-connection.sh -- <connection-name> [provisioning-interface]
#
# When provisioning-interface is provided (immediate mode):
# 1. Reloads connection files from disk
# 2. Protects the provisioning interface's default route with metric 1
# 3. Activates the connection (nmcli connection up)
#
# When only connection-name is provided (deferred mode):
# 1. Reloads connection files from disk (activation deferred to reboot)
#
# Called by BoardingPass via the command allow-list.
set -euo pipefail
[ "${1:-}" = "--" ] && shift

if [ $# -lt 1 ]; then
echo "Usage: reload-connection.sh <connection-name>" >&2
echo "Usage: reload-connection.sh <connection-name> [provisioning-interface]" >&2
exit 1
fi

CONN_NAME="$1"
PROV_IFACE="${2:-}"

# Validate connection name (alphanumeric + hyphens + underscores)
# Validate connection name (alphanumeric + hyphens + underscores + dots)
if ! [[ "$CONN_NAME" =~ ^[a-zA-Z0-9._-]+$ ]]; then
echo "Error: invalid connection name '$CONN_NAME'" >&2
exit 1
fi

# Validate provisioning interface name if provided
if [ -n "$PROV_IFACE" ] && ! [[ "$PROV_IFACE" =~ ^[a-zA-Z0-9._-]+$ ]]; then
echo "Error: invalid interface name '$PROV_IFACE'" >&2
exit 1
fi

# Reload all connection files from disk so NetworkManager picks up the new profile.
# We intentionally do NOT force-activate (nmcli connection up) because during
# provisioning we may be communicating over a different interface. Force-activating
# a connection with a gateway would add a competing default route whose lower metric
# (e.g. 100 for ethernet vs 600 for WiFi) could hijack traffic away from the
# provisioning interface, breaking the session. The profile has autoconnect=true,
# so NetworkManager will activate it when the carrier is detected or on reboot.
nmcli connection reload

echo "Connection '$CONN_NAME' reloaded"
if [ -n "$PROV_IFACE" ]; then
# Immediate mode: protect provisioning interface route, then activate.
#
# Activating a connection with a gateway adds a default route (e.g. metric 100
# for ethernet). To prevent this from hijacking traffic away from the
# provisioning interface, we temporarily add a high-priority (metric 1) default
# route via the provisioning interface. This route is ephemeral and disappears
# on reboot.
PROV_GW=$(ip route show default dev "$PROV_IFACE" 2>/dev/null | awk '/default/ {print $3; exit}' || true)
if [ -n "$PROV_GW" ]; then
ip route replace default via "$PROV_GW" dev "$PROV_IFACE" metric 1 2>/dev/null || true
echo "Protected provisioning route via $PROV_IFACE (metric 1)"
fi

nmcli connection up "$CONN_NAME"
echo "Connection '$CONN_NAME' reloaded and activated"
else
# Deferred mode: reload only. The profile has autoconnect=true, so
# NetworkManager will activate it when the carrier is detected or on reboot.
echo "Connection '$CONN_NAME' reloaded"
fi
15 changes: 12 additions & 3 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ type Server struct {
mu sync.Mutex
}

// DefaultCertValidDays is the certificate validity period used for regeneration.
const DefaultCertValidDays = 365

// New creates a new API server instance.
func New(cfg *config.Config, logger *logging.Logger) (*Server, error) {
mux := http.NewServeMux()
Expand All @@ -43,14 +46,20 @@ func New(cfg *config.Config, logger *logging.Logger) (*Server, error) {
config: cfg,
}

// Configure TLS
tlsCfg, err := tlspkg.NewServerConfig(
// Configure TLS with CertManager for dynamic SAN support.
// When a TLS handshake arrives on an IP not in the cert's SANs,
// the cert is regenerated automatically.
certMgr, err := tlspkg.NewCertManager(
cfg.Service.TLSCert,
cfg.Service.TLSKey,
DefaultCertValidDays,
logger,
)
if err != nil {
return nil, fmt.Errorf("failed to create TLS config: %w", err)
return nil, fmt.Errorf("failed to create cert manager: %w", err)
}

tlsCfg := certMgr.ServerTLSConfig()
server.httpServer.TLSConfig = tlsCfg
server.tlsConfig = tlsCfg

Expand Down
65 changes: 55 additions & 10 deletions internal/tls/certgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ func buildSANs() (dnsNames []string, ipAddresses []net.IP) {
}

// GenerateSelfSignedCert generates a self-signed TLS certificate and key.
// Returns the paths to the generated cert and key files.
//
//nolint:gosec // G304: File paths are from config, G302: 0644 is appropriate for certs
func GenerateSelfSignedCert(certPath, keyPath string, validDays int) error {
Expand All @@ -109,17 +108,40 @@ func GenerateSelfSignedCert(certPath, keyPath string, validDays int) error {
return fmt.Errorf("failed to generate private key: %w", err)
}

// Generate a random serial number
if err := writeKeyFile(keyPath, privateKey); err != nil {
return err
}

return writeCertFile(certPath, privateKey, validDays)
}

// RegenerateCert creates a new certificate using the existing private key.
// The new cert captures current network interfaces in its SANs while
// preserving the same public key (SPKI), minimizing TOFU disruption.
//
//nolint:gosec // G304: File paths are from config, G302: 0644 is appropriate for certs
func RegenerateCert(certPath, keyPath string, validDays int) error {
privateKey, err := loadPrivateKey(keyPath)
if err != nil {
return fmt.Errorf("failed to load existing private key: %w", err)
}

return writeCertFile(certPath, privateKey, validDays)
}

// writeCertFile creates a self-signed certificate using the given key and
// writes it to certPath. SANs are populated from current network state.
//
//nolint:gosec // G304: File paths are from config, G302: 0644 is appropriate for certs
func writeCertFile(certPath string, privateKey *ecdsa.PrivateKey, validDays int) error {
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return fmt.Errorf("failed to generate serial number: %w", err)
}

// Build SANs (includes static + dynamic hostname/IPs)
dnsNames, ipAddresses := buildSANs()

// Create certificate template
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Expand All @@ -133,18 +155,15 @@ func GenerateSelfSignedCert(certPath, keyPath string, validDays int) error {
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,

// Add SANs for maximum compatibility (static + dynamic)
DNSNames: dnsNames,
IPAddresses: ipAddresses,
}

// Create self-signed certificate
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return fmt.Errorf("failed to create certificate: %w", err)
}

// Write certificate to file
certFile, err := os.Create(certPath)
if err != nil {
return fmt.Errorf("failed to create cert file: %w", err)
Expand All @@ -159,13 +178,18 @@ func GenerateSelfSignedCert(certPath, keyPath string, validDays int) error {
return fmt.Errorf("failed to write cert: %w", err)
}

// Set certificate file permissions (read-only for all)
//nolint:gofumpt // formatting is acceptable
if err := os.Chmod(certPath, 0644); err != nil {
return fmt.Errorf("failed to set cert permissions: %w", err)
}

// Write private key to file
return nil
}

// writeKeyFile marshals an ECDSA private key and writes it to keyPath.
//
//nolint:gosec // G304: File paths are from config
func writeKeyFile(keyPath string, privateKey *ecdsa.PrivateKey) (err error) {
keyFile, err := os.Create(keyPath)
if err != nil {
return fmt.Errorf("failed to create key file: %w", err)
Expand All @@ -185,7 +209,6 @@ func GenerateSelfSignedCert(certPath, keyPath string, validDays int) error {
return fmt.Errorf("failed to write key: %w", err)
}

// Set key file permissions (read-only for owner)
//nolint:gofumpt // formatting is acceptable
if err := os.Chmod(keyPath, 0600); err != nil {
return fmt.Errorf("failed to set key permissions: %w", err)
Expand All @@ -194,6 +217,28 @@ func GenerateSelfSignedCert(certPath, keyPath string, validDays int) error {
return nil
}

// loadPrivateKey reads an ECDSA private key from a PEM file.
//
//nolint:gosec // G304: Key path is from config
func loadPrivateKey(keyPath string) (*ecdsa.PrivateKey, error) {
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to read key file: %w", err)
}

block, _ := pem.Decode(keyPEM)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block from key file")
}

key, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse EC private key: %w", err)
}

return key, nil
}

// CertificateExists checks if both certificate and key files exist.
func CertificateExists(certPath, keyPath string) bool {
if _, err := os.Stat(certPath); os.IsNotExist(err) {
Expand Down
Loading
Loading