Первичная настройка, часть 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:
@@ -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")
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ;;
|
||||
# Перезапуск службы модуля из админки. Список закрытый: чужую службу
|
||||
|
||||
Executable
+36
@@ -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
|
||||
Executable
+46
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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