Files
tunnel/internal/server/store.go
T
TZGyn 682a341010 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.
2026-10-25 11:52:45 +08:00

194 lines
5.0 KiB
Go

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")
}