Первичная настройка, часть 4: HTTPS веб-интерфейса (:8443, самоподписанный сертификат на имя и адреса сервера, свой сертификат, перенаправление браузера с HTTP); сервер времени из настроек (ntpd/chrony/timesyncd — bj-system-admin); постоянный адрес — bj-network (netplan try с откатом), вопрос при первой загрузке с образа; раздел «Сервер: HTTPS, время, сеть»; HTTPS в итоговой проверке; адреса https:// на экране установщика и консоли
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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: создан новый самоподписанный сертификат на имя сервера и его адреса")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user