Files
Bridge-and-Join-s/internal/lkgateway/tlsserve.go
T
fontvielle 2df7b0f54a Мастер: авто-синхронизация времени (ВНИИФТРИ) и доменные имена сервера
Шаг «Время и сеть»:
- Кнопка «Синхронизировать»: bj сам пробует российские серверы точного
  времени ВНИИФТРИ (мини-SNTP зонд по UDP/123), и только если из сети они
  недоступны — просит указать свой сервер (контроллер домена/маршрутизатор).
  Статус времени берётся из timedatectl (timeSynced), а не из флага шага.
- Поле «Доменное имя сервера»: имена вписываются в самоподписанный сертификат
  (SAN), чтобы интерфейс открывался по имени без предупреждения браузера.
  Сертификат перевыпускается на лету; A-запись заводит админ сети на DNS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XG3zNMMf2LMoDpXo22mwMX
2026-10-16 15:15:09 +03:00

329 lines
12 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"
"sync/atomic"
"time"
)
// HTTPSSettings — HTTPS веб-интерфейса.
type HTTPSSettings struct {
Port int `json:"port,omitempty"` // 0 — 8443
// RedirectOff — не перенаправлять браузер с HTTP на HTTPS. С 06.10.2026 перенаправление включено сразу
// (самоподписанный сертификат bj создаёт сам): пароли не должны идти по сети открытым текстом.
RedirectOff bool `json:"redirect_off,omitempty"`
Custom bool `json:"custom,omitempty"` // загружен сертификат организации (не самоподписанный)
// Hostnames — доменные имена сервера (например, m2m.corp.example.ru). DNS организации
// (контроллер домена) направляет их на bj, а bj вписывает их в самоподписанный сертификат,
// чтобы браузер не ругался на имя. Пусто — только имя хоста и его IP.
Hostnames []string `json:"hostnames,omitempty"`
}
// RedirectOn — браузер с HTTP перенаправляется на HTTPS.
func (h HTTPSSettings) RedirectOn() bool { return !h.RedirectOff }
// httpsUp — HTTPS действительно слушает порт: иначе перенаправление оставило бы без входа в bj.
var httpsUp atomic.Bool
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()
names := uniqStrings(append([]string{pickStr(host, "bj-server"), "localhost", pickStr(host, "bj-server") + ".local"}, customHostnames()...))
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: names,
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.RedirectOn() && httpsUp.Load() && 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)
})
}
// selfSignedStale — самоподписанный сертификат выписан не на все нынешние адреса сервера (адрес сменили,
// копию восстановили на другой машине): браузер ругался бы ещё и на имя — выпускаем заново.
func selfSignedStale() bool {
raw, err := os.ReadFile(tlsCertPath())
if err != nil {
return false
}
b, _ := pem.Decode(raw)
if b == nil {
return false
}
c, err := x509.ParseCertificate(b.Bytes)
if err != nil {
return false
}
_, ips := localAddrs()
for _, ip := range ips {
if ip.IsLoopback() || ip.To4() == nil {
continue
}
found := false
for _, have := range c.IPAddresses {
if have.Equal(ip) {
found = true
break
}
}
if !found {
return true
}
}
have := map[string]bool{}
for _, n := range c.DNSNames {
have[strings.ToLower(n)] = true
}
for _, n := range customHostnames() {
if n = strings.ToLower(strings.TrimSpace(n)); n != "" && !have[n] {
return true
}
}
return false
}
// startHTTPS — слушатель HTTPS рядом с основным HTTP. Ошибка не роняет bj: интерфейс остаётся по HTTP.
func startHTTPS(rc *RuntimeConfig, handler http.Handler) *http.Server {
if !rc.Snapshot().HTTPS.Custom && selfSignedStale() {
log.Printf("https: адреса сервера изменились — новый самоподписанный сертификат")
_ = ensureSelfSigned(true)
}
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},
}
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
log.Printf("https: %v — HTTPS выключен, вход по HTTP без перенаправления", err)
return nil
}
httpsUp.Store(true)
go func() {
log.Printf("lk-gateway: listen https %s", srv.Addr)
if err := srv.ServeTLS(ln, "", ""); err != nil && err != http.ErrServerClosed {
httpsUp.Store(false)
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.RedirectOff = r.FormValue("redirect") == ""
if err := rc.UpdateHTTPS(h); err != nil {
setupFlash(w, r, "HTTPS: "+err.Error())
return
}
msg := "HTTPS: перенаправление с HTTP выключено"
if h.RedirectOn() {
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: создан новый самоподписанный сертификат на имя сервера и его адреса")
})
}