209 lines
5.5 KiB
Go
209 lines
5.5 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"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"`
|
|
CreatedAt string `json:"createdAt,omitempty"`
|
|
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),
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
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
|
|
}
|
|
|
|
// Tunnels returns every tunnel, sorted by hostname (for the admin dashboard).
|
|
func (s *Store) Tunnels() []storedTunnel {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]storedTunnel, 0, len(s.data.Tunnels))
|
|
for _, t := range s.data.Tunnels {
|
|
out = append(out, t)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Hostname < out[j].Hostname })
|
|
return out
|
|
}
|
|
|
|
// 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")
|
|
}
|