Первичная настройка, часть 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:
+36
-12
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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" .}}
|
||||
|
||||
Reference in New Issue
Block a user