Первичная настройка, часть 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
+5 -3
View File
@@ -59,6 +59,7 @@ const (
usersFile = "/var/lib/bj/.bj/users.json"
firstRunFile = "/var/lib/bj/.bj/first-run.json"
bjPort = 8080
bjHTTPSPort = 8443 // веб-вход по HTTPS (самоподписанный сертификат сервера; см. lkgateway/tlsserve.go)
)
type installer struct {
@@ -890,7 +891,7 @@ func setupURLs() []string {
addrs, _ := ifc.Addrs()
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok && ipn.IP.To4() != nil {
out = append(out, fmt.Sprintf("http://%s:%d", ipn.IP.String(), bjPort))
out = append(out, fmt.Sprintf("https://%s:%d", ipn.IP.String(), bjHTTPSPort))
}
}
}
@@ -1025,15 +1026,16 @@ func (c *console) done(code string, l *license.License) {
if code != "" {
fmt.Fprintf(c.w, " Откройте в браузере на своём компьютере:\n")
if len(urls) == 0 {
urls = []string{fmt.Sprintf("http://<адрес сервера>:%d", bjPort)}
urls = []string{fmt.Sprintf("https://<адрес сервера>:%d", bjHTTPSPort)}
}
for _, u := range urls {
fmt.Fprintf(c.w, " %s\n", c.paint("1", u+"/first-run"))
}
fmt.Fprintf(c.w, " Браузер предупредит о сертификате: это сертификат самого сервера bj — подтвердите исключение.\n Сертификат организации можно загрузить позже: «Настройка → Сервер».\n")
fmt.Fprintf(c.w, "\n Одноразовый код первичной настройки: %s\n\n", c.paint("1;33", code))
fmt.Fprintf(c.w, " По коду создаётся администратор, дальше в разделе «Настройка»:\n организация и код участника НРД, ключи и интеграционный шлюз, учётные системы, пользователи.\n")
} else {
fmt.Fprintf(c.w, " Адрес bj: %s\n", c.paint("1", firstOr(urls, fmt.Sprintf("http://<адрес сервера>:%d", bjPort))))
fmt.Fprintf(c.w, " Адрес bj: %s\n", c.paint("1", firstOr(urls, fmt.Sprintf("https://<адрес сервера>:%d", bjHTTPSPort))))
}
if l == nil {
fmt.Fprintf(c.w, "\n Ключ продукта ещё не введён — его спросит первая страница в браузере\n (или позже: «Настройка → Лицензия»). Он есть в портале клиента.\n")
+8 -1
View File
@@ -14,7 +14,7 @@ finish() {
systemctl disable bj-firstboot.service >/dev/null 2>&1
[ -x /usr/local/sbin/bj-welcome ] && /usr/local/sbin/bj-welcome
}
urls() { ip -4 -o addr show scope global 2>/dev/null | awk '{split($4,a,"/"); printf " http://%s:8080%s\n", a[1], "'"$1"'"}'; }
urls() { ip -4 -o addr show scope global 2>/dev/null | awk '{split($4,a,"/"); printf " https://%s:8443%s\n", a[1], "'"$1"'"}'; }
# Пока идут вопросы, сообщения загрузки на консоль не выводятся (ядро — только аварийные,
# systemd — без строк «[ OK ] Started …»), иначе они перемешиваются с текстом на экране.
quiet_console() { dmesg -n 1 2>/dev/null; kill -s RTMIN+21 1 2>/dev/null; sleep 2; }
@@ -57,6 +57,13 @@ show_accounts() {
printf ' Сменить пароль: passwd (под своей учётной записью)\n\n'
}
setup_accounts
# постоянный адрес: DHCP может выдать серверу другой адрес, и bj «потеряется» (только Ubuntu/netplan)
if [ ! -f /var/lib/bj/.bj/network-asked ] && [ -x /usr/local/sbin/bj-network ] && command -v netplan >/dev/null; then
mkdir -p /var/lib/bj/.bj && date -Iseconds > /var/lib/bj/.bj/network-asked
printf '\n Сейчас адрес сервера: %s\n' "$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | tr '\n' ' ')"
read -t 60 -r -p " Задать постоянный адрес (рекомендуется)? [д/Н]: " ANS < /dev/tty || ANS=""
case "$ANS" in [дДyY]*) /usr/local/sbin/bj-network < /dev/tty ;; esac
fi
clear
printf '\n Bridge&Join: установка на сервер\n ────────────────────────────────────────────────────────────\n'
printf ' Шаг 2 из 2 — установка bj. Система установлена, осталось поставить bj.\n\n'
+1
View File
@@ -28,6 +28,7 @@ install -m 0755 "$INSTALLER" "$WORK/bj/bj-installer"
install -m 0755 "$HERE/bj-firstboot" "$WORK/bj/bj-firstboot"
cp "$HERE/bj-firstboot.service" "$WORK/bj/"
install -m 0755 "$HERE/../linux/sbin/bj-welcome" "$WORK/bj/bj-welcome"
install -m 0755 "$HERE/../linux/sbin/bj-network" "$WORK/bj/bj-network"
cp "$HERE/../systemd/bj-welcome.service" "$HERE/../systemd/bj-welcome.timer" "$WORK/bj/"
echo "$CHANNEL" > "$WORK/bj/channel"; echo "$VERSION" > "$WORK/bj/version"
+1 -1
View File
@@ -30,7 +30,7 @@ autoinstall:
- cp -r /cdrom/bj/bundle /target/var/lib/bj/bundle
- cp -r /cdrom/bj/validata /target/var/lib/bj/validata
- sh -c 'if [ -d /cdrom/bj/debs ]; then cp -r /cdrom/bj/debs /target/var/lib/bj/debs; fi'
- install -m 0755 /cdrom/bj/bj-installer /cdrom/bj/bj-firstboot /cdrom/bj/bj-welcome /target/usr/local/sbin/
- install -m 0755 /cdrom/bj/bj-installer /cdrom/bj/bj-firstboot /cdrom/bj/bj-welcome /cdrom/bj/bj-network /target/usr/local/sbin/
- install -m 0644 /cdrom/bj/bj-firstboot.service /cdrom/bj/bj-welcome.service /cdrom/bj/bj-welcome.timer /target/etc/systemd/system/
- install -m 0644 /cdrom/bj/channel /target/etc/bj/channel
- curtin in-target --target=/target -- systemctl enable bj-firstboot.service bj-welcome.service bj-welcome.timer
+2
View File
@@ -13,6 +13,8 @@ process_one(){
component-install) out=$(/usr/local/sbin/bj-component-install "$staging" 2>&1) ;;
# интеграционный шлюз НРД: состояние, установка, канал контура (TEST3 | боевой); параметры — JSON в extra
igate-status|igate-install|igate-contour) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-igate-admin "${action#igate-}" 2>&1) ;;
# системные настройки сервера: серверы времени (NTP)
system-ntp) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-system-admin ntp 2>&1) ;;
# резервное копирование и восстановление (настройки bj → bj-backup-admin); параметры — JSON в extra
backup-config|backup-test|backup-run|backup-list|backup-verify|backup-restore) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-backup-admin "${action#backup-}" 2>&1) ;;
# Перезапуск службы модуля из админки. Список закрытый: чужую службу
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# bj-network — постоянный адрес сервера bj (на консоли сервера, под sudo). Ubuntu (netplan): спрашивает
# адрес, шлюз и DNS и применяет через «netplan try» — если связь пропала и подтверждения нет 120 секунд,
# прежние настройки возвращаются сами. Запускается и при первой загрузке с образа (bj-firstboot).
set -u
[ "$(id -u)" = 0 ] || { echo "запустите через sudo: sudo bj-network"; exit 1; }
command -v netplan >/dev/null || { echo "netplan не найден: на этой системе сеть настраивается её средствами (/etc/network/interfaces)."; exit 1; }
IF=$(ip -4 route show default 2>/dev/null | awk '{print $5; exit}')
[ -n "$IF" ] || IF=$(ip -o link show | awk -F': ' '$2!="lo"{print $2; exit}')
CUR=$(ip -4 -o addr show dev "$IF" scope global 2>/dev/null | awk '{print $4; exit}')
GW=$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')
DNS=$(resolvectl dns "$IF" 2>/dev/null | sed 's/.*: *//' | tr ' ' ',' )
printf '\n Сеть сервера: интерфейс %s, адрес %s, шлюз %s, DNS %s\n' "$IF" "${CUR:-нет}" "${GW:-нет}" "${DNS:-нет}"
printf ' Постоянный адрес нужен, чтобы адрес bj не менялся (DHCP может выдать другой).\n\n'
ask() { local v; read -r -p " $1 [$2]: " v; printf '%s' "${v:-$2}"; }
ADDR=$(ask "Адрес с маской (например 10.10.10.30/24)" "${CUR:-}")
[[ "$ADDR" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$ ]] || { echo " адрес — вида 10.10.10.30/24"; exit 1; }
GW2=$(ask "Шлюз" "${GW:-}")
[[ "$GW2" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || { echo " шлюз — вида 10.10.10.1"; exit 1; }
DNS2=$(ask "DNS через запятую" "${DNS:-$GW2}")
F=/etc/netplan/60-bj-static.yaml
[ -f "$F" ] && cp -a "$F" "$F.bak"
{
printf 'network:\n version: 2\n ethernets:\n %s:\n dhcp4: false\n addresses: [%s]\n routes:\n - to: default\n via: %s\n nameservers:\n addresses: [%s]\n' "$IF" "$ADDR" "$GW2" "$DNS2"
} > "$F"
chmod 600 "$F"
# прежние файлы с DHCP для этого интерфейса — выключаем, чтобы не было двух адресов
for f in /etc/netplan/*.yaml; do [ "$f" = "$F" ] && continue; grep -q "$IF" "$f" 2>/dev/null && mv "$f" "$f.disabled-by-bj"; done
echo " Применяю. Если связь пропадёт — через 120 секунд вернутся прежние настройки."
if netplan try --timeout 120; then
echo " ✓ Адрес ${ADDR%/*} закреплён. Веб-вход: https://${ADDR%/*}:8443/"
[ -x /usr/local/sbin/bj-welcome ] && /usr/local/sbin/bj-welcome
else
for f in /etc/netplan/*.disabled-by-bj; do [ -f "$f" ] && mv "$f" "${f%.disabled-by-bj}"; done
rm -f "$F"; netplan apply; echo " Настройки не приняты — оставлен прежний адрес."
fi
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# bj-system-admin — системные настройки сервера bj из веб-интерфейса (через root-демон bj-keymedia).
# ntp — серверы времени (BJ_EXTRA: "ntp1.example.ru ntp2.example.ru"): ntpd, chrony или systemd-timesyncd —
# что стоит на сервере. Время важно для подписей и обмена с НРД.
set -uo pipefail
q() { python3 -c "import json,sys;print(json.dumps(sys.argv[1],ensure_ascii=False))" "$1"; }
ok() { echo "{\"ok\":true,\"output\":$(q "$1")}"; }
err() { echo "{\"ok\":false,\"error\":$(q "$1")}"; exit 0; }
cmd=${1:-}
case "$cmd" in
ntp)
servers=$(printf '%s' "${BJ_EXTRA:-}" | tr ',;' ' ' | xargs)
[ -n "$servers" ] || err "не указан сервер времени"
for s in $servers; do [[ "$s" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$ ]] || err "недопустимое имя сервера времени: $s"; done
if systemctl is-active --quiet ntp || systemctl is-active --quiet ntpd || [ -f /etc/ntp.conf ] && ! systemctl is-active --quiet chrony && ! systemctl is-active --quiet systemd-timesyncd; then
conf=/etc/ntp.conf; [ -f "$conf" ] || err "нет /etc/ntp.conf"
cp -a "$conf" "$conf.bak-bj" 2>/dev/null
python3 - "$conf" $servers <<'PY' || err "не удалось изменить /etc/ntp.conf"
import sys,re
p,servers=sys.argv[1],sys.argv[2:]
out=[]
for l in open(p).read().split("\n"):
if l.startswith("server ") and l.rstrip().endswith("# bj"): continue # прежние наши
if re.match(r"^(server|pool) ",l): l="#bj# "+l # чужие — выключаем
out.append(l)
out+=[f"server {s} iburst # bj" for s in servers]
open(p,"w").write("\n".join(out).rstrip("\n")+"\n")
PY
svc=ntp; systemctl is-active --quiet ntpd && svc=ntpd
systemctl restart "$svc" || err "служба $svc не перезапустилась"
ok "серверы времени: $servers (служба $svc)"
elif systemctl is-active --quiet chrony || [ -d /etc/chrony ]; then
d=/etc/chrony/sources.d; mkdir -p "$d"
: > "$d/bj.sources"; for s in $servers; do echo "server $s iburst" >> "$d/bj.sources"; done
grep -q '^sourcedir /etc/chrony/sources.d' /etc/chrony/chrony.conf 2>/dev/null || echo 'sourcedir /etc/chrony/sources.d' >> /etc/chrony/chrony.conf
systemctl restart chrony || err "служба chrony не перезапустилась"
ok "серверы времени: $servers (служба chrony)"
else
mkdir -p /etc/systemd/timesyncd.conf.d
printf '[Time]\nNTP=%s\n' "$servers" > /etc/systemd/timesyncd.conf.d/bj.conf
timedatectl set-ntp true >/dev/null 2>&1
systemctl restart systemd-timesyncd || err "служба systemd-timesyncd не перезапустилась"
ok "серверы времени: $servers (служба systemd-timesyncd)"
fi ;;
*) err "неизвестная команда: $cmd" ;;
esac
+2 -1
View File
@@ -10,10 +10,11 @@ SETUP=/var/lib/bj/.bj/setup.json
USERS=/var/lib/bj/.bj/users.json
FIRSTRUN=/var/lib/bj/.bj/first-run.json
PORT=8080
TLSPORT=8443 # веб-вход по HTTPS (сертификат сервера — браузер попросит подтвердить исключение)
HOST=$(hostname 2>/dev/null || echo bj)
ADDRS=$(ip -4 -o addr show scope global 2>/dev/null | awk '{split($4,a,"/"); print a[1]}' | tr '\n' ' ')
[ -z "$ADDRS" ] && ADDRS="<адрес не получен: проверьте сеть>"
url() { for a in $ADDRS; do case "$a" in \<*) printf ' http://%s:%s%s\n' "<IP сервера>" "$PORT" "$1";; *) printf ' http://%s:%s%s\n' "$a" "$PORT" "$1";; esac; done; }
url() { for a in $ADDRS; do case "$a" in \<*) printf ' https://%s:%s%s\n' "<IP сервера>" "$TLSPORT" "$1";; *) printf ' https://%s:%s%s\n' "$a" "$TLSPORT" "$1";; esac; done; }
lic=""
if [ -r "$SETUP" ]; then
lic=$(python3 - "$SETUP" 2>/dev/null <<'PY'
+6 -6
View File
@@ -1,11 +1,11 @@
{
"bj-component-install": {
"fingerprint": "24f9297c039f38fa4438103d061dcaad9c7998d8c193a47ed8f38bff0bc6f23b",
"version": "2026.09.25.15"
"fingerprint": "c6935ab9b97592629f8f997cabfb76fe64b9b41ad043150232753f46c5740774",
"version": "2026.10.05.15"
},
"bj-installer": {
"fingerprint": "4be46092947872b652d193edaf58ae40cb7836be36f173e5fcff87d8ff746330",
"version": "2026.09.25.24"
"fingerprint": "39d6dca9b7789bc7cc585ba30958bfb4625a1d41cea149ec4dcfd998cf901045",
"version": "2026.10.05.15"
},
"bj-lk": {
"fingerprint": "37bf4ba7d13bc3cfb52032dfb95851b9d5d702f1a2244e185a1b6a5b87a5cfc6",
@@ -20,8 +20,8 @@
"version": "2026.09.25.14"
},
"bj-server": {
"fingerprint": "16dfbfc7331a989f6344379136d8900a5ded00eb1dd9fa29af36980b1ed3f3b2",
"version": "2026.09.29.2"
"fingerprint": "c07806b81189f3963bc74b209159b64ca2358e52f6587f11754d47bf7528cb47",
"version": "2026.10.05.15"
},
"crypto-service.jar": {
"fingerprint": "2c4fd536bc9b1c20c69a70e8dcd374a61847b263aeb2e474c45debb01fbd8233",
+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" .}}