You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

279 lines
7.7 KiB

package managementaccess
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"log"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"tera-clone/services/tera-api/internal/domain"
)
type RuntimeOptions struct {
BindHost string
Handler http.Handler
CertFile string
KeyFile string
TLSHosts []string
Logger *log.Logger
}
type managedEndpoint struct {
kind string
port int
stop func()
}
type Runtime struct {
options RuntimeOptions
tlsLoader *certificateReloader
mu sync.Mutex
endpoints map[string]*managedEndpoint
}
func NewRuntime(options RuntimeOptions) (*Runtime, error) {
if options.Handler == nil {
return nil, errors.New("management access web handler is required")
}
if strings.TrimSpace(options.BindHost) == "" {
options.BindHost = "0.0.0.0"
}
if options.CertFile == "" || options.KeyFile == "" {
return nil, errors.New("management access TLS certificate and key paths are required")
}
if options.Logger == nil {
options.Logger = log.Default()
}
return &Runtime{
options: options,
tlsLoader: &certificateReloader{certFile: options.CertFile, keyFile: options.KeyFile},
endpoints: make(map[string]*managedEndpoint),
}, nil
}
func endpointKey(kind string, port int) string { return fmt.Sprintf("%s:%d", kind, port) }
func (r *Runtime) Apply(ctx context.Context, settings domain.ManagementAccessSettings) error {
if err := Validate(settings); err != nil {
return err
}
desired := make(map[string]struct{})
if settings.HTTPEnabled {
desired[endpointKey("http", settings.HTTPPort)] = struct{}{}
}
if settings.HTTPSEnabled {
desired[endpointKey("https", settings.HTTPSPort)] = struct{}{}
}
r.mu.Lock()
defer r.mu.Unlock()
started := make(map[string]*managedEndpoint)
for key := range desired {
if _, exists := r.endpoints[key]; exists {
continue
}
parts := strings.Split(key, ":")
kind := parts[0]
var port int
if _, err := fmt.Sscanf(parts[1], "%d", &port); err != nil {
return err
}
endpoint, err := r.startEndpoint(kind, port, time.Duration(settings.IdleTimeoutMinutes)*time.Minute)
if err != nil {
for _, item := range started {
item.stop()
}
return fmt.Errorf("start %s management listener on port %d: %w", kind, port, err)
}
started[key] = endpoint
}
for key, endpoint := range r.endpoints {
if _, keep := desired[key]; !keep {
endpoint.stop()
delete(r.endpoints, key)
}
}
for key, endpoint := range started {
r.endpoints[key] = endpoint
}
return nil
}
func (r *Runtime) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
for key, endpoint := range r.endpoints {
endpoint.stop()
delete(r.endpoints, key)
}
return nil
}
func (r *Runtime) startEndpoint(kind string, port int, idle time.Duration) (*managedEndpoint, error) {
address := net.JoinHostPort(r.options.BindHost, fmt.Sprintf("%d", port))
listener, err := net.Listen("tcp", address)
if err != nil {
return nil, err
}
switch kind {
case "http":
server := &http.Server{Handler: r.options.Handler, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: idle}
go r.serveHTTP(kind, port, server, listener)
return &managedEndpoint{kind: kind, port: port, stop: func() { _ = server.Close(); _ = listener.Close() }}, nil
case "https":
_, err := ensureTLSCertificate(r.options.CertFile, r.options.KeyFile, r.options.TLSHosts)
if err != nil {
_ = listener.Close()
return nil, err
}
tlsListener := tls.NewListener(listener, &tls.Config{
MinVersion: tls.VersionTLS12, GetCertificate: r.tlsLoader.GetCertificate,
NextProtos: []string{"http/1.1"},
})
server := &http.Server{Handler: r.options.Handler, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: idle}
go r.serveHTTP(kind, port, server, tlsListener)
return &managedEndpoint{kind: kind, port: port, stop: func() { _ = server.Close(); _ = tlsListener.Close() }}, nil
default:
_ = listener.Close()
return nil, errors.New("unsupported management listener")
}
}
type certificateReloader struct {
mu sync.Mutex
certFile, keyFile string
certMod, keyMod time.Time
certificate *tls.Certificate
}
func (r *certificateReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
r.mu.Lock()
defer r.mu.Unlock()
certInfo, err := os.Stat(r.certFile)
if err != nil {
return nil, err
}
keyInfo, err := os.Stat(r.keyFile)
if err != nil {
return nil, err
}
if r.certificate != nil && certInfo.ModTime().Equal(r.certMod) && keyInfo.ModTime().Equal(r.keyMod) {
return r.certificate, nil
}
certificate, err := tls.LoadX509KeyPair(r.certFile, r.keyFile)
if err != nil {
return nil, err
}
r.certMod, r.keyMod, r.certificate = certInfo.ModTime(), keyInfo.ModTime(), &certificate
return r.certificate, nil
}
func (r *Runtime) serveHTTP(kind string, port int, server *http.Server, listener net.Listener) {
r.options.Logger.Printf("tera-accessd %s listener active on %s", kind, listener.Addr())
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
r.options.Logger.Printf("tera-accessd %s listener on port %d failed: %v", kind, port, err)
}
}
func ensureTLSCertificate(certPath, keyPath string, hosts []string) (tls.Certificate, error) {
if certificate, err := tls.LoadX509KeyPair(certPath, keyPath); err == nil {
return certificate, nil
}
if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil {
return tls.Certificate{}, err
}
if filepath.Dir(keyPath) != filepath.Dir(certPath) {
if err := os.MkdirAll(filepath.Dir(keyPath), 0o700); err != nil {
return tls.Certificate{}, err
}
}
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return tls.Certificate{}, err
}
now := time.Now().UTC()
template := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "TERA CORA-X48 PRO K"},
NotBefore: now.Add(-time.Hour), NotAfter: now.AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
for _, host := range hosts {
host = strings.TrimSpace(host)
if host == "" {
continue
}
if ip := net.ParseIP(host); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, host)
}
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return tls.Certificate{}, err
}
keyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return tls.Certificate{}, err
}
if err := writeExclusive(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}), 0o600); err != nil {
return tls.Certificate{}, err
}
if err := writeExclusive(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
_ = os.Remove(keyPath)
return tls.Certificate{}, err
}
return tls.LoadX509KeyPair(certPath, keyPath)
}
func writeExclusive(path string, payload []byte, mode os.FileMode) error {
temporary := path + ".new"
file, err := os.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
failed := true
defer func() {
_ = file.Close()
if failed {
_ = os.Remove(temporary)
}
}()
if _, err := file.Write(payload); err != nil {
return err
}
if err := file.Sync(); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
if err := os.Rename(temporary, path); err != nil {
return err
}
failed = false
return nil
}