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: создан новый самоподписанный сертификат на имя сервера и его адреса") }) }