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

567 lines
20 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 (
"bufio"
"context"
"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"`
// StdPorts — bj слушает и стандартные порты: 443 (HTTPS) и 80 (переход на https://), адрес без «:8443».
StdPorts bool `json:"std_ports,omitempty"`
}
// https443Up — bj слушает 443; std443Err — почему не слушает (нет права — включить ещё раз).
var (
https443Up atomic.Bool
std443Err atomic.Value // string
)
// publicPort — порт HTTPS в адресах и переходах: 443, если bj его слушает, иначе свой (8443).
func (h HTTPSSettings) publicPort() int {
if h.StdPorts && https443Up.Load() {
return 443
}
return h.port()
}
// httpsHostPort — «имя» для 443 и «имя:порт» для остальных.
func httpsHostPort(host string, port int) string {
if port == 443 {
if net.ParseIP(host) != nil && strings.Contains(host, ":") {
return "[" + host + "]"
}
return host
}
return net.JoinHostPort(host, strconv.Itoa(port))
}
// 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://"+httpsHostPort(hn, h.publicPort())+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)
ln = newHTTPSSniffListener(ln, rc.Snapshot().HTTPS.port())
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)
}
}()
if rc.Snapshot().HTTPS.StdPorts {
startStdPorts(srv)
}
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
}
// одна форма раздела: перенаправление, доменное имя, адрес без порта. Применяется только изменённое;
// порты — последними: bj после них перезапускается.
h := rc.Snapshot().HTTPS
var msgs []string
if off := r.FormValue("redirect") == ""; off != h.RedirectOff {
h.RedirectOff = off
if err := rc.UpdateHTTPS(h); err != nil {
setupFlash(w, r, "HTTPS: "+err.Error())
return
}
if h.RedirectOn() {
msgs = append(msgs, "Браузеры перенаправляются с http:// на https://.")
} else {
msgs = append(msgs, "Перенаправление с http:// выключено.")
}
}
if _, sent := r.Form["hostnames"]; sent && strings.Join(strings.Fields(strings.ToLower(r.FormValue("hostnames"))), " ") != strings.Join(h.Hostnames, " ") {
msg, ok := saveHostnames(r.Context(), rc, r.FormValue("hostnames"))
msgs = append(msgs, msg)
if !ok {
setupFlash(w, r, strings.Join(msgs, " "))
return
}
}
// включено, но порт 443 не поднялся — «Сохранить» повторяет выдачу права
if on := r.FormValue("std_ports") != ""; on != rc.Snapshot().HTTPS.StdPorts || (on && stdPortsState() != "ok") {
msgs = append(msgs, applyStdPorts(r.Context(), rc, on))
}
if len(msgs) == 0 {
msgs = append(msgs, "HTTPS: изменений нет.")
}
setupFlash(w, r, strings.Join(msgs, " "))
})
// адрес без номера порта: 443 и 80 (право на порты — через root-помощника, bj перезапускается)
mux.HandleFunc("/admin/setup/https/stdports", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
setupFlash(w, r, applyStdPorts(r.Context(), rc, r.FormValue("std_ports") != ""))
})
// свой сертификат организации (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: создан новый самоподписанный сертификат на имя сервера и его адреса")
})
}
// httpsSniffListener — порт HTTPS принимает и обычный http://: такой запрос перенаправляется (308) на
// https:// того же имени и порта, вместо ответа Go «Client sent an HTTP request to an HTTPS server».
// TLS-соединение узнаётся по первому байту (0x16 — handshake) и идёт дальше без изменений.
type httpsSniffListener struct {
net.Listener
conns chan net.Conn
errs chan error
port int
}
func newHTTPSSniffListener(ln net.Listener, port int) net.Listener {
l := &httpsSniffListener{Listener: ln, conns: make(chan net.Conn, 16), errs: make(chan error, 1), port: port}
go func() {
for {
c, err := ln.Accept()
if err != nil {
l.errs <- err
return
}
go l.sniff(c)
}
}()
return l
}
func (l *httpsSniffListener) Accept() (net.Conn, error) {
select {
case c := <-l.conns:
return c, nil
case err := <-l.errs:
return nil, err
}
}
func (l *httpsSniffListener) sniff(c net.Conn) {
_ = c.SetReadDeadline(time.Now().Add(10 * time.Second))
br := bufio.NewReader(c)
b, err := br.Peek(1)
if err != nil {
_ = c.Close()
return
}
_ = c.SetReadDeadline(time.Time{})
if b[0] == 0x16 {
l.conns <- &peekedConn{Conn: c, r: br}
return
}
defer c.Close()
_ = c.SetReadDeadline(time.Now().Add(10 * time.Second))
req, err := http.ReadRequest(br)
if err != nil {
return
}
host := req.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if net.ParseIP(host) == nil && !validHostname(strings.ToLower(host)) {
host = "localhost"
}
port := l.port
if globalRC != nil && globalRC.Snapshot().HTTPS.publicPort() == 443 {
port = 443
}
target := "https://" + httpsHostPort(host, port) + req.URL.RequestURI()
_, _ = c.Write([]byte("HTTP/1.1 308 Permanent Redirect\r\nLocation: " + target + "\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"))
}
// peekedConn — соединение, у которого уже прочитан (подсмотрен) первый байт.
type peekedConn struct {
net.Conn
r *bufio.Reader
}
func (p *peekedConn) Read(b []byte) (int, error) { return p.r.Read(b) }
// serverIPs — адреса этого сервера (для проверки, куда указывает доменное имя).
func serverIPs() []string {
var out []string
addrs, _ := net.InterfaceAddrs()
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && !n.IP.IsLoopback() && !n.IP.IsLinkLocalUnicast() {
out = append(out, n.IP.String())
}
}
return out
}
// startStdPorts — 443 (тот же HTTPS) и 80 (переход на https://имя/). Нет права на порты <1024 — bj
// работает по 8443, а в настройках видно, что нужно включить ещё раз (выдать право через помощника).
func startStdPorts(srv *http.Server) {
ln, err := net.Listen("tcp", ":443")
if err != nil {
std443Err.Store(err.Error())
log.Printf("https: порт 443: %v — адрес без номера порта недоступен", err)
return
}
std443Err.Store("")
https443Up.Store(true)
go func() {
log.Printf("lk-gateway: listen https :443")
if err := srv.ServeTLS(newHTTPSSniffListener(ln, 443), "", ""); err != nil && err != http.ErrServerClosed {
https443Up.Store(false)
log.Printf("https :443: %v", err)
}
}()
ln80, err := net.Listen("tcp", ":80")
if err != nil {
log.Printf("http: порт 80: %v — http://имя/ без порта не откроется (https://имя/ работает)", err)
return
}
redir := &http.Server{ReadHeaderTimeout: 5 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if net.ParseIP(host) == nil && !validHostname(strings.ToLower(host)) {
http.Error(w, "bad host", http.StatusBadRequest)
return
}
http.Redirect(w, r, "https://"+httpsHostPort(host, 443)+r.URL.RequestURI(), http.StatusMovedPermanently)
})}
go func() {
log.Printf("lk-gateway: listen http :80 (переход на https://)")
_ = redir.Serve(ln80)
}()
}
// stdPortsState — для настроек: включено ли и работает ли.
func stdPortsState() string {
h := globalRC.Snapshot().HTTPS
switch {
case !h.StdPorts:
return ""
case https443Up.Load():
return "ok"
}
if e, _ := std443Err.Load().(string); e != "" {
return e
}
return "ожидает перезапуска bj"
}
// applyStdPorts — адрес без номера порта: сохранить настройку и выдать/снять службе право на 443/80
// (через root-помощника; bj после этого перезапускается). Возвращает текст для пользователя.
func applyStdPorts(ctx0 context.Context, rc *RuntimeConfig, on bool) string {
h := rc.Snapshot().HTTPS
h.StdPorts = on
if err := rc.UpdateHTTPS(h); err != nil {
return "HTTPS: " + err.Error()
}
mode := "off"
if on {
mode = "on"
}
ctx, cancel := context.WithTimeout(ctx0, 40*time.Second)
res, err := runKeymediaOp(ctx, "system-ports", map[string]string{"extra": mode})
cancel()
if err == nil {
if ok, _ := res["ok"].(bool); !ok {
err = fmt.Errorf("%v", res["error"])
}
}
if err != nil {
return "Адрес без номера порта: настройка сохранена, но право на порты не выдано — " + err.Error()
}
name := "имя-сервера"
if len(h.Hostnames) > 0 {
name = h.Hostnames[0]
}
if on {
return "Готово: bj перезапускается (несколько секунд) и будет открываться по https://" + name + "/ — без :" + strconv.Itoa(h.port()) + "; http://" + name + "/ перейдёт на https:// сам. Обновите страницу через 10 секунд."
}
return "Адрес без номера порта выключен: bj перезапускается и открывается по https://" + name + ":" + strconv.Itoa(h.port()) + "/."
}