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.
280 lines
7.7 KiB
280 lines
7.7 KiB
|
2 months ago
|
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
|
||
|
|
}
|