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()) + "/." }