Files
Bridge-and-Join-s/internal/lkgateway/tlsserve.go
T

261 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
// HTTPS для веб-интерфейса bj (05.10.2026). Пароли администратора, SQL, хранилища и ключ продукта не должны
// идти по сети открытым текстом. bj слушает HTTPS рядом с HTTP (по умолчанию :8443) с самоподписанным
// сертификатом, который создаёт сам при первом запуске (на имя сервера и все его адреса); можно загрузить
// сертификат организации. Перенаправление браузера с HTTP на HTTPS — настройкой: локальные модули bj и API
// ходят по HTTP на localhost и не перенаправляются.
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"log"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// HTTPSSettings — HTTPS веб-интерфейса.
type HTTPSSettings struct {
Port int `json:"port,omitempty"` // 0 — 8443
Redirect bool `json:"redirect,omitempty"` // браузер с HTTP — на HTTPS
Custom bool `json:"custom,omitempty"` // загружен сертификат организации (не самоподписанный)
}
func (h HTTPSSettings) port() int {
if h.Port > 0 && h.Port < 65536 {
return h.Port
}
return 8443
}
var tlsDir = "/var/lib/bj/.bj/tls"
func tlsCertPath() string { return filepath.Join(tlsDir, "cert.pem") }
func tlsKeyPath() string { return filepath.Join(tlsDir, "key.pem") }
// localAddrs — имя сервера и его адреса (для сертификата и подсказок).
func localAddrs() (string, []net.IP) {
host, _ := os.Hostname()
var ips []net.IP
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && !n.IP.IsLinkLocalUnicast() {
ips = append(ips, n.IP)
}
}
}
return host, ips
}
// ensureSelfSigned — создать самоподписанный сертификат, если сертификата нет.
func ensureSelfSigned(force bool) error {
if !force {
if _, err := os.Stat(tlsCertPath()); err == nil {
return nil
}
}
if err := os.MkdirAll(tlsDir, 0o700); err != nil {
return err
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
host, ips := localAddrs()
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 62))
tpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: pickStr(host, "bj-server"), Organization: []string{"Bridge&Join"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(5, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{pickStr(host, "bj-server"), "localhost", pickStr(host, "bj-server") + ".local"},
IPAddresses: ips,
}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
if err != nil {
return err
}
kb, err := x509.MarshalECPrivateKey(key)
if err != nil {
return err
}
if err := os.WriteFile(tlsKeyPath(), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0o600); err != nil {
return err
}
return os.WriteFile(tlsCertPath(), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644)
}
// tlsCertCache — сертификат с диска; перечитывается при замене файла (загрузка своего — без перезапуска).
var tlsCertCache struct {
sync.Mutex
mod time.Time
cert *tls.Certificate
}
func loadTLSCert(*tls.ClientHelloInfo) (*tls.Certificate, error) {
tlsCertCache.Lock()
defer tlsCertCache.Unlock()
st, err := os.Stat(tlsCertPath())
if err != nil {
return nil, err
}
if tlsCertCache.cert != nil && st.ModTime().Equal(tlsCertCache.mod) {
return tlsCertCache.cert, nil
}
c, err := tls.LoadX509KeyPair(tlsCertPath(), tlsKeyPath())
if err != nil {
return nil, err
}
tlsCertCache.cert, tlsCertCache.mod = &c, st.ModTime()
return &c, nil
}
// tlsCertInfo — кому выдан сертификат HTTPS и до какого числа.
func tlsCertInfo() (subject string, notAfter time.Time, err error) {
raw, err := os.ReadFile(tlsCertPath())
if err != nil {
return "", time.Time{}, err
}
b, _ := pem.Decode(raw)
if b == nil {
return "", time.Time{}, fmt.Errorf("сертификат не в формате PEM")
}
c, err := x509.ParseCertificate(b.Bytes)
if err != nil {
return "", time.Time{}, err
}
return c.Subject.CommonName, c.NotAfter, nil
}
// httpsRedirect — браузер с HTTP на HTTPS (если включено). API, проверка работы и запросы с этого же
// сервера (модули bj) не перенаправляются.
func httpsRedirect(rc *RuntimeConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := rc.Snapshot().HTTPS
if h.Redirect && r.TLS == nil && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/healthz" {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() {
hn := r.Host
if hh, _, err := net.SplitHostPort(hn); err == nil {
hn = hh
}
http.Redirect(w, r, "https://"+net.JoinHostPort(hn, strconv.Itoa(h.port()))+r.URL.RequestURI(), http.StatusFound)
return
}
}
next.ServeHTTP(w, r)
})
}
// startHTTPS — слушатель HTTPS рядом с основным HTTP. Ошибка не роняет bj: интерфейс остаётся по HTTP.
func startHTTPS(rc *RuntimeConfig, handler http.Handler) *http.Server {
if err := ensureSelfSigned(false); err != nil {
log.Printf("https: сертификат не создан: %v — HTTPS выключен", err)
return nil
}
srv := &http.Server{
Addr: ":" + strconv.Itoa(rc.Snapshot().HTTPS.port()),
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
TLSConfig: &tls.Config{GetCertificate: loadTLSCert, MinVersion: tls.VersionTLS12},
}
go func() {
log.Printf("lk-gateway: listen https %s", srv.Addr)
if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
log.Printf("https: %v", err)
}
}()
return srv
}
func registerHTTPS(mux *http.ServeMux, rc *RuntimeConfig) {
mux.HandleFunc("/admin/setup/https", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
h := rc.Snapshot().HTTPS
h.Redirect = r.FormValue("redirect") != ""
if err := rc.UpdateHTTPS(h); err != nil {
setupFlash(w, r, "HTTPS: "+err.Error())
return
}
msg := "HTTPS: перенаправление с HTTP выключено"
if h.Redirect {
msg = "HTTPS: браузеры теперь перенаправляются на https://…:" + strconv.Itoa(h.port())
}
setupFlash(w, r, msg)
})
// свой сертификат организации (PEM: сертификат с цепочкой и закрытый ключ)
mux.HandleFunc("/admin/setup/https/cert", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
read := func(name string) []byte {
if f, _, err := r.FormFile(name); err == nil {
defer f.Close()
b, _ := io.ReadAll(io.LimitReader(f, 1<<20))
return b
}
return nil
}
cert, key := read("cert"), read("key")
if len(cert) == 0 || len(key) == 0 {
setupFlash(w, r, "HTTPS: нужны оба файла — сертификат (PEM, с цепочкой) и закрытый ключ (PEM)")
return
}
if _, err := tls.X509KeyPair(cert, key); err != nil {
setupFlash(w, r, "HTTPS: сертификат и ключ не подходят друг к другу или не в формате PEM: "+err.Error())
return
}
_ = os.MkdirAll(tlsDir, 0o700)
if err := os.WriteFile(tlsKeyPath(), key, 0o600); err != nil {
setupFlash(w, r, "HTTPS: "+err.Error())
return
}
if err := os.WriteFile(tlsCertPath(), cert, 0o644); err != nil {
setupFlash(w, r, "HTTPS: "+err.Error())
return
}
h := rc.Snapshot().HTTPS
h.Custom = true
_ = rc.UpdateHTTPS(h)
cn, until, _ := tlsCertInfo()
setupFlash(w, r, "HTTPS: установлен сертификат «"+cn+"» до "+until.Format("02.01.2006")+" — действует сразу")
})
mux.HandleFunc("/admin/setup/https/selfsigned", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if err := ensureSelfSigned(true); err != nil {
setupFlash(w, r, "HTTPS: "+err.Error())
return
}
h := rc.Snapshot().HTTPS
h.Custom = false
_ = rc.UpdateHTTPS(h)
setupFlash(w, r, "HTTPS: создан новый самоподписанный сертификат на имя сервера и его адреса")
})
}