M1: single-node OpenTunnel-compatible server
HTTP API per the recorded contract, bridge WebSocket, SNI relay with pass-through tunnel TLS, local CA issuance, JSON store. Verified end-to-end with the official opentunnel CLI.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// storedCert is one issued certificate. With the local issuer, issuance is
|
||||
// synchronous, so a bound CSR always has its certificate populated.
|
||||
type storedCert struct {
|
||||
ID string `json:"id"`
|
||||
CSR string `json:"csr"`
|
||||
Certificate string `json:"certificate"`
|
||||
Chain string `json:"chain"`
|
||||
Expiry string `json:"expiry"`
|
||||
}
|
||||
|
||||
type storedTunnel struct {
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
TokenHash string `json:"tokenHash"`
|
||||
Certificate *storedCert `json:"certificate,omitempty"`
|
||||
}
|
||||
|
||||
// storeData is the on-disk shape. The CA key is a secret, so the file is
|
||||
// written 0600.
|
||||
type storeData struct {
|
||||
CAKeyPEM string `json:"caKeyPem"`
|
||||
CACertPEM string `json:"caCertPEM"`
|
||||
ServerKey string `json:"serverKey"`
|
||||
ServerCrt string `json:"serverCrt"`
|
||||
Tunnels map[string]storedTunnel `json:"tunnels"`
|
||||
}
|
||||
|
||||
// Store is a JSON-file-backed tunnel store: adequate for a single-node
|
||||
// self-hosted deployment, and the one writer this server has.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
data storeData
|
||||
}
|
||||
|
||||
// openStore loads path, or initializes it (with a fresh local CA) when it
|
||||
// does not exist. An empty path keeps state in memory only.
|
||||
func OpenStore(path string) (*Store, error) {
|
||||
s := &Store{path: path, data: storeData{Tunnels: map[string]storedTunnel{}}}
|
||||
if path != "" {
|
||||
if raw, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(raw, &s.data); err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
if s.data.Tunnels == nil {
|
||||
s.data.Tunnels = map[string]storedTunnel{}
|
||||
}
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if s.data.CAKeyPEM == "" {
|
||||
ca, err := newLocalCA()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPEM, err := ca.keyPEM()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.data.CAKeyPEM = string(keyPEM)
|
||||
s.data.CACertPEM = string(ca.certPEM)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ca returns the store's local CA, loading it from the stored PEM.
|
||||
func (s *Store) ca() (*localCA, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return loadLocalCA([]byte(s.data.CAKeyPEM), []byte(s.data.CACertPEM))
|
||||
}
|
||||
|
||||
// CACertPEM is the CA certificate clients should trust.
|
||||
func (s *Store) CACertPEM() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.CACertPEM
|
||||
}
|
||||
|
||||
// serverCertificate returns the API domain's key and certificate PEM,
|
||||
// issuing one with the CA when missing.
|
||||
func (s *Store) ServerCertificate(domain string) (certPEM, keyPEM []byte, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.data.ServerCrt != "" {
|
||||
return []byte(s.data.ServerCrt), []byte(s.data.ServerKey), nil
|
||||
}
|
||||
ca, err := loadLocalCA([]byte(s.data.CAKeyPEM), []byte(s.data.CACertPEM))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
certPEM, keyPEM, err = ca.issue(domain)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
s.data.ServerCrt = string(certPEM)
|
||||
s.data.ServerKey = string(keyPEM)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// createTunnel stores a new tunnel and returns it with its bearer token, the
|
||||
// only time the token is visible.
|
||||
func (s *Store) createTunnel(domain string) (storedTunnel, string, error) {
|
||||
token := newToken()
|
||||
t := storedTunnel{
|
||||
ID: slug(),
|
||||
TokenHash: hashToken(token),
|
||||
}
|
||||
t.Hostname = t.ID + "." + domain
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.data.Tunnels[t.ID] = t
|
||||
return t, token, s.saveLocked()
|
||||
}
|
||||
|
||||
// get returns the tunnel with id, or false.
|
||||
func (s *Store) get(id string) (storedTunnel, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t, ok := s.data.Tunnels[id]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// bindCSR records the issued certificate for the tunnel. The CSR is kept so a
|
||||
// renewal (and a resumed provision) can be answered without a new bind.
|
||||
func (s *Store) bindCertificate(id string, cert storedCert) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t, ok := s.data.Tunnels[id]
|
||||
if !ok {
|
||||
return errors.New("tunnel not found")
|
||||
}
|
||||
t.Certificate = &cert
|
||||
s.data.Tunnels[id] = t
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// remove deletes the tunnel.
|
||||
func (s *Store) remove(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.data.Tunnels[id]; !ok {
|
||||
return errors.New("tunnel not found")
|
||||
}
|
||||
delete(s.data.Tunnels, id)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// saveLocked writes the data file atomically (0600); a no-op for an
|
||||
// in-memory store.
|
||||
func (s *Store) saveLocked() error {
|
||||
if s.path == "" {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.MarshalIndent(s.data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// expiryString formats a time as the reference does: ISO 8601 UTC with
|
||||
// milliseconds, e.g. 2026-10-03T00:00:00.000Z.
|
||||
func expiryString(t time.Time) string {
|
||||
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
}
|
||||
Reference in New Issue
Block a user