765d7b5994
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
261 lines
9.2 KiB
Go
261 lines
9.2 KiB
Go
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: создан новый самоподписанный сертификат на имя сервера и его адреса")
|
||
})
|
||
}
|