Первичная настройка, часть 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:
zuevav
2026-10-05 16:43:23 +03:00
parent 4332e3309b
commit 765d7b5994
17 changed files with 582 additions and 26 deletions
+36 -12
View File
@@ -61,18 +61,42 @@ var templateFuncs = template.FuncMap{
"depoAccPrefix": depoAccPrefix,
"certDaysLeft": certDaysLeft,
"currentContour": func() string { return contourOf(currentNSDProfile()) },
"intList": func(v ...int) []int { return v },
"nsdSegTip": nsdSegTip,
"devOn": authTemplateFuncs["devOn"],
"devLicensed": authTemplateFuncs["devLicensed"],
"mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии
"isIIS": incomingIsIIS, // входящий перевод на ИИС
"depoSubs": func() any { return depoWizardSubs }, // экраны шага 3 мастера
"authOpen": authOpen,
"internalDocs": func() bool { return internalDocsBuilt },
"roleTitle": roleTitle,
"stageCls": func(r depoQueueRow) string { c, _ := r.StageRu(); return c },
"stageText": func(r depoQueueRow) string { _, t := r.StageRu(); return t },
"httpsCert": func() map[string]any {
cn, until, err := tlsCertInfo()
return map[string]any{"CN": cn, "Until": until, "Err": err, "Days": certDaysLeft(until)}
},
"serverAddrs": func() string {
host, ips := localAddrs()
var a []string
for _, ip := range ips {
if ip.To4() != nil && !ip.IsLoopback() {
a = append(a, ip.String())
}
}
return host + " · " + strings.Join(a, ", ")
},
"serverIP": func() string {
_, ips := localAddrs()
for _, ip := range ips {
if ip.To4() != nil && !ip.IsLoopback() {
return ip.String()
}
}
return "IP-сервера"
},
"timeSynced": func() bool { ok, _ := timeStatus(context.Background()); return ok },
"intList": func(v ...int) []int { return v },
"nsdSegTip": nsdSegTip,
"devOn": authTemplateFuncs["devOn"],
"devLicensed": authTemplateFuncs["devLicensed"],
"mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии
"isIIS": incomingIsIIS, // входящий перевод на ИИС
"depoSubs": func() any { return depoWizardSubs }, // экраны шага 3 мастера
"authOpen": authOpen,
"internalDocs": func() bool { return internalDocsBuilt },
"roleTitle": roleTitle,
"stageCls": func(r depoQueueRow) string { c, _ := r.StageRu(); return c },
"stageText": func(r depoQueueRow) string { _, t := r.StageRu(); return t },
"deref": func(v *float64) float64 {
if v == nil {
return 0
+39
View File
@@ -105,3 +105,42 @@ func TestFirstRunRestoreNeedsCode(t *testing.T) {
t.Fatalf("без ожидающей первичной настройки восстановление не должно запускаться: %s", rec.Body.String())
}
}
func TestHTTPSSelfSignedAndRedirect(t *testing.T) {
old := tlsDir
tlsDir = t.TempDir()
defer func() { tlsDir = old }()
if err := ensureSelfSigned(false); err != nil {
t.Fatal(err)
}
if cn, until, err := tlsCertInfo(); err != nil || cn == "" || certDaysLeft(until) < 1000 {
t.Fatalf("сертификат: %q %v %v", cn, until, err)
}
if _, err := loadTLSCert(nil); err != nil {
t.Fatal(err)
}
rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json")
_ = rc.UpdateHTTPS(HTTPSSettings{Redirect: true})
h := httpsRedirect(rc, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }))
cases := []struct {
path, remote string
want int
}{
{"/admin/setup", "10.10.10.5:5000", http.StatusFound},
{"/api/v1/lk/x", "10.10.10.5:5000", http.StatusTeapot},
{"/healthz", "10.10.10.5:5000", http.StatusTeapot},
{"/admin/setup", "127.0.0.1:5000", http.StatusTeapot},
}
for _, c := range cases {
req := httptest.NewRequest(http.MethodGet, "http://10.10.10.6:8080"+c.path, nil)
req.RemoteAddr = c.remote
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("%s с %s: %d, ожидалось %d", c.path, c.remote, rec.Code, c.want)
}
if c.want == http.StatusFound && rec.Header().Get("Location") != "https://10.10.10.6:8443/admin/setup" {
t.Errorf("перенаправление: %s", rec.Header().Get("Location"))
}
}
}
+16 -1
View File
@@ -192,7 +192,22 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck {
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 11, Title: "Время сервера", Link: "/admin/setup#time"}
c := ReadyCheck{Order: 12, Title: "HTTPS", Link: "/admin/setup#server"}
cn, until, err := tlsCertInfo()
switch {
case err != nil:
c.State, c.Detail = "warn", "сертификат HTTPS: "+err.Error()
case certDaysLeft(until) < 14:
c.State, c.Detail = "warn", fmt.Sprintf("сертификат «%s» истекает %s — замените", cn, until.Format("02.01.2006"))
case !s.HTTPS.Redirect:
c.State, c.Detail = "warn", "вход в bj по http:// не перенаправляется на https:// — пароли могут идти по сети открытым текстом"
default:
c.State, c.Detail = "ok", fmt.Sprintf("вход только по HTTPS, сертификат «%s» до %s", cn, until.Format("02.01.2006"))
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 11, Title: "Время сервера", Link: "/admin/setup#server"}
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(cctx, "timedatectl", "show", "-p", "NTPSynchronized", "--value").Output()
+20
View File
@@ -40,6 +40,8 @@ type Settings struct {
QuikExport json.RawMessage `json:"quik_export,omitempty"` // устарело: настройки у службы bj-quikexport
Auth AuthSettings `json:"auth"`
Backup BackupSettings `json:"backup,omitempty"`
HTTPS HTTPSSettings `json:"https,omitempty"`
Time TimeSettings `json:"time,omitempty"`
LastTest *TestRunResult `json:"last_test,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -469,6 +471,24 @@ func (r *RuntimeConfig) UpdateBackup(s BackupSettings) error {
return r.save()
}
// UpdateHTTPS сохраняет настройки HTTPS веб-интерфейса.
func (r *RuntimeConfig) UpdateHTTPS(s HTTPSSettings) error {
r.mu.Lock()
r.data.HTTPS = s
r.data.UpdatedAt = time.Now()
r.mu.Unlock()
return r.save()
}
// UpdateTime сохраняет настройки синхронизации времени.
func (r *RuntimeConfig) UpdateTime(s TimeSettings) error {
r.mu.Lock()
r.data.Time = s
r.data.UpdatedAt = time.Now()
r.mu.Unlock()
return r.save()
}
// UpdateNews заменяет всю ленту новостей.
func (r *RuntimeConfig) UpdateNews(s NewsSettings) error {
r.mu.Lock()
+6 -1
View File
@@ -219,6 +219,8 @@ func NewServer(cfg ServerConfig) (*Server, error) {
registerIgate(mux, rc) // интеграционный шлюз НРД: состояние и установка (igate.go)
registerReadiness(mux, rc) // итоговая проверка готовности (мастер, шаг 10)
registerQuikReady(mux, rc) // проверка модуля неторговых поручений QUIK (только чтение)
registerHTTPS(mux, rc) // HTTPS веб-интерфейса: перенаправление, свой сертификат
registerTime(mux, rc) // сервер времени (NTP)
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk)
registerHealth(mux)
registerQuikOps(mux, rc, svc)
@@ -240,7 +242,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
incoming: incomingStore,
server: &http.Server{
Addr: cfg.Addr,
Handler: authMiddleware(rc, mux),
Handler: httpsRedirect(rc, authMiddleware(rc, mux)),
ReadHeaderTimeout: 5 * time.Second,
},
}, nil
@@ -385,6 +387,9 @@ func (s *Server) Run(ctx context.Context) error {
log.Printf("lk-gateway: listen %s", s.cfg.Addr)
errCh <- s.server.ListenAndServe()
}()
if hs := startHTTPS(s.rc, s.server.Handler); hs != nil {
defer hs.Close()
}
select {
case <-ctx.Done():
+61
View File
@@ -0,0 +1,61 @@
package lkgateway
// Сервер времени (NTP) — из веб-интерфейса (05.10.2026). В закрытом контуре внешние серверы времени
// недоступны и часы уплывают, а время важно для подписей и обмена с НРД. Меняет root-скрипт
// bj-system-admin (ntpd, chrony или systemd-timesyncd — что стоит на сервере).
import (
"context"
"net/http"
"os/exec"
"strings"
"time"
)
// TimeSettings — серверы времени, заданные в bj.
type TimeSettings struct {
Servers string `json:"servers,omitempty"`
SavedAt time.Time `json:"saved_at,omitempty"`
}
// timeStatus — синхронизируются ли часы и текущее время сервера.
func timeStatus(ctx context.Context) (synced bool, now string) {
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(cctx, "timedatectl", "show", "-p", "NTPSynchronized", "--value").Output()
return err == nil && strings.TrimSpace(string(out)) == "yes", time.Now().Format("02.01.2006 15:04:05 MST")
}
func registerTime(mux *http.ServeMux, rc *RuntimeConfig) {
mux.HandleFunc("/admin/setup/time", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
servers := strings.Join(strings.Fields(strings.NewReplacer(",", " ", ";", " ").Replace(r.FormValue("servers"))), " ")
if servers == "" {
setupFlash(w, r, "Время: укажите сервер времени (NTP) — адрес или имя")
return
}
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
res, err := runKeymediaOp(ctx, "system-ntp", map[string]string{"extra": servers})
if err != nil {
setupFlash(w, r, "Время: "+err.Error())
return
}
if res["ok"] != true {
setupFlash(w, r, "Время: "+pickStr(asString(res["error"]), "не применено"))
return
}
_ = rc.UpdateTime(TimeSettings{Servers: servers, SavedAt: time.Now()})
setupFlash(w, r, "Время: "+asString(res["output"])+". Синхронизация займёт до нескольких минут.")
})
}
func asString(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
+260
View File
@@ -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: создан новый самоподписанный сертификат на имя сервера и его адреса")
})
}
@@ -18,6 +18,7 @@
<button data-sec="alerts"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></span>Оповещения<span class="ind {{if or .Settings.Alerts.SMTP.Enabled .Settings.Alerts.Zabbix.Enabled}}ok{{end}}"></span></button>
<button data-sec="update"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V8M6 12l6-6 6 6"/><path d="M5 4h14"/></svg></span>Обновления{{if .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="backup"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6" rx="7" ry="2.5"/><path d="M5 6v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V6"/><path d="M12 17v4M9 19l3 2 3-2"/></svg></span>Резервное копирование{{$bs := backupStatus}}{{if and .Settings.Backup.Enabled $bs}}{{if not $bs.OK}}<span class="ind err"></span>{{end}}{{else if not .Settings.Backup.Enabled}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="server"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/></svg></span>Сервер: HTTPS, время, сеть{{if or (not .Settings.HTTPS.Redirect) (not timeSynced)}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="license"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="12" r="4"/><path d="M12 12h9M18 12v3M15 12v2"/></svg></span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button>
<button data-sec="branding"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 16l-5-5L5 19"/></svg></span>Оформление{{if .Settings.Branding.LogoDataURI}}<span class="ind ok"></span>{{end}}</button>
{{if devOn}} <button data-sec="advanced"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>Перспективные{{if .AdvancedUnlocked}}<span class="ind ok"></span>{{end}}</button>{{end}}
@@ -936,6 +937,42 @@
{{/* ============ ЛИЦЕНЗИЯ ============ */}}
{{/* ============ РЕЗЕРВНОЕ КОПИРОВАНИЕ ============ */}}
<section class="settings-section" id="sec-server">
<h1>Сервер: HTTPS, время, сеть</h1>
{{$h := httpsCert}}
<div class="card" id="sec-https">
<h2><span class="dot {{if and .Settings.HTTPS.Redirect (not $h.Err)}}ok{{else}}warn{{end}}"></span>HTTPS — шифрование входа в bj</h2>
<p class="muted">Пароли администратора, баз данных, хранилища и ключ продукта не должны идти по сети открытым текстом. bj всегда слушает HTTPS: <a href="https://{{serverIP}}:{{if .Settings.HTTPS.Port}}{{.Settings.HTTPS.Port}}{{else}}8443{{end}}/admin/">https://{{serverIP}}:{{if .Settings.HTTPS.Port}}{{.Settings.HTTPS.Port}}{{else}}8443{{end}}/</a>. Модули bj и API на этом сервере работают по HTTP, как раньше.</p>
<p style="margin:8px 0">{{if $h.Err}}<span style="color:var(--err)">✗ сертификат: {{$h.Err}}</span>{{else}}Сертификат: <b>{{$h.CN}}</b>, до {{$h.Until.Format "02.01.2006"}} — {{if .Settings.HTTPS.Custom}}сертификат организации{{else}}самоподписанный: браузер один раз попросит подтвердить исключение; чтобы не просил — загрузите сертификат организации{{end}}.{{end}}</p>
<form method="post" action="/admin/setup/https" style="margin:8px 0">
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="redirect" style="width:auto"{{if .Settings.HTTPS.Redirect}} checked{{end}}> <b>Открывать bj только по HTTPS</b> — браузер с http:// перенаправляется на https://</label>
<button type="submit" class="btn" style="margin-top:8px">Сохранить</button>
</form>
<details style="margin-top:8px"><summary style="cursor:pointer;color:var(--accent)">Сертификат организации</summary>
<form method="post" action="/admin/setup/https/cert" enctype="multipart/form-data" style="display:grid;gap:8px;max-width:520px;margin-top:8px">
<label>Сертификат (PEM, с цепочкой)<input type="file" name="cert" accept=".pem,.crt,.cer" style="width:100%"></label>
<label>Закрытый ключ (PEM)<input type="file" name="key" accept=".pem,.key" style="width:100%"></label>
<div><button type="submit" class="btn">Установить сертификат</button></div>
</form>
<form method="post" action="/admin/setup/https/selfsigned" style="margin-top:8px"><button type="submit" class="btn btn-secondary" onclick="return confirm('Создать новый самоподписанный сертификат? Сертификат организации будет заменён.')">Вернуть самоподписанный</button></form>
</details>
</div>
<div class="card" id="time">
<h2><span class="dot {{if timeSynced}}ok{{else}}warn{{end}}"></span>Время сервера</h2>
<p class="muted">Точное время нужно для подписей и обмена с НРД. В закрытом контуре внешние серверы времени недоступны — укажите сервер времени вашей сети (обычно контроллер домена или маршрутизатор).</p>
<p style="margin:8px 0">{{if timeSynced}}<span style="color:var(--ok)">✓ время синхронизируется</span>{{else}}<span style="color:var(--warn)">⚠ время не синхронизируется</span>{{end}}{{if .Settings.Time.Servers}} · серверы bj: <code>{{.Settings.Time.Servers}}</code>{{end}}</p>
<form method="post" action="/admin/setup/time" style="display:flex;gap:8px;flex-wrap:wrap;align-items:end;max-width:640px">
<label style="flex:1;min-width:240px;margin:0">Серверы времени (через пробел)<input name="servers" value="{{.Settings.Time.Servers}}" placeholder="10.10.10.1 ntp.example.ru" style="width:100%;margin-top:4px"></label>
<button type="submit" class="btn">Применить</button>
</form>
</div>
<div class="card" id="sec-network">
<h2>Сеть</h2>
<p style="margin:6px 0">Сервер: <code>{{serverAddrs}}</code></p>
<p class="muted">Чтобы адрес bj не менялся, задайте серверу постоянный адрес — на консоли сервера (или по SSH): <code>sudo bj-network</code>. Утилита спросит адрес, шлюз и DNS и применит их с защитой: если связь пропадёт, через 2 минуты вернутся прежние настройки. На Astra Linux сеть настраивается средствами системы.</p>
</div>
</section>
<section class="settings-section" id="sec-backup">
<h1>Резервное копирование</h1>
{{template "backupCard" .}}