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 }