diff --git a/cmd/bj-installer/main.go b/cmd/bj-installer/main.go index dd1ec50..b9bcea5 100644 --- a/cmd/bj-installer/main.go +++ b/cmd/bj-installer/main.go @@ -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") diff --git a/deploy/iso/bj-firstboot b/deploy/iso/bj-firstboot index 2578698..b19e3cd 100755 --- a/deploy/iso/bj-firstboot +++ b/deploy/iso/bj-firstboot @@ -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' diff --git a/deploy/iso/build-iso-ubuntu.sh b/deploy/iso/build-iso-ubuntu.sh index cfbf003..7e462dc 100755 --- a/deploy/iso/build-iso-ubuntu.sh +++ b/deploy/iso/build-iso-ubuntu.sh @@ -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" diff --git a/deploy/iso/ubuntu/user-data.tmpl b/deploy/iso/ubuntu/user-data.tmpl index b2e6424..3955722 100644 --- a/deploy/iso/ubuntu/user-data.tmpl +++ b/deploy/iso/ubuntu/user-data.tmpl @@ -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 diff --git a/deploy/linux/sbin/bj-keymedia-worker b/deploy/linux/sbin/bj-keymedia-worker index f1250d6..8dc844c 100755 --- a/deploy/linux/sbin/bj-keymedia-worker +++ b/deploy/linux/sbin/bj-keymedia-worker @@ -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) ;; # Перезапуск службы модуля из админки. Список закрытый: чужую службу diff --git a/deploy/linux/sbin/bj-network b/deploy/linux/sbin/bj-network new file mode 100755 index 0000000..7fe4145 --- /dev/null +++ b/deploy/linux/sbin/bj-network @@ -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 diff --git a/deploy/linux/sbin/bj-system-admin b/deploy/linux/sbin/bj-system-admin new file mode 100755 index 0000000..3ff4c98 --- /dev/null +++ b/deploy/linux/sbin/bj-system-admin @@ -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 diff --git a/deploy/linux/sbin/bj-welcome b/deploy/linux/sbin/bj-welcome index 8b5e3f4..88a54ab 100755 --- a/deploy/linux/sbin/bj-welcome +++ b/deploy/linux/sbin/bj-welcome @@ -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' "" "$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' "" "$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' diff --git a/deploy/release/component-versions.json b/deploy/release/component-versions.json index 21467c5..da2f9df 100644 --- a/deploy/release/component-versions.json +++ b/deploy/release/component-versions.json @@ -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", diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index c32d14b..53c93ce 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -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 diff --git a/internal/lkgateway/onboarding_test.go b/internal/lkgateway/onboarding_test.go index 306494a..caf3ae4 100644 --- a/internal/lkgateway/onboarding_test.go +++ b/internal/lkgateway/onboarding_test.go @@ -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")) + } + } +} diff --git a/internal/lkgateway/readiness.go b/internal/lkgateway/readiness.go index b00b161..2b786c3 100644 --- a/internal/lkgateway/readiness.go +++ b/internal/lkgateway/readiness.go @@ -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() diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 97af9aa..6fad29b 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -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() diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index c392d31..b8a08a8 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -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(): diff --git a/internal/lkgateway/timesync.go b/internal/lkgateway/timesync.go new file mode 100644 index 0000000..2ca8101 --- /dev/null +++ b/internal/lkgateway/timesync.go @@ -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 "" +} diff --git a/internal/lkgateway/tlsserve.go b/internal/lkgateway/tlsserve.go new file mode 100644 index 0000000..a4d132b --- /dev/null +++ b/internal/lkgateway/tlsserve.go @@ -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: создан новый самоподписанный сертификат на имя сервера и его адреса") + }) +} diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index c4c51df..2472828 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -18,6 +18,7 @@ + {{if devOn}} {{end}} @@ -936,6 +937,42 @@ {{/* ============ ЛИЦЕНЗИЯ ============ */}} {{/* ============ РЕЗЕРВНОЕ КОПИРОВАНИЕ ============ */}} +
+

Сервер: HTTPS, время, сеть

+ {{$h := httpsCert}} +
+

HTTPS — шифрование входа в bj

+

Пароли администратора, баз данных, хранилища и ключ продукта не должны идти по сети открытым текстом. bj всегда слушает HTTPS: https://{{serverIP}}:{{if .Settings.HTTPS.Port}}{{.Settings.HTTPS.Port}}{{else}}8443{{end}}/. Модули bj и API на этом сервере работают по HTTP, как раньше.

+

{{if $h.Err}}✗ сертификат: {{$h.Err}}{{else}}Сертификат: {{$h.CN}}, до {{$h.Until.Format "02.01.2006"}} — {{if .Settings.HTTPS.Custom}}сертификат организации{{else}}самоподписанный: браузер один раз попросит подтвердить исключение; чтобы не просил — загрузите сертификат организации{{end}}.{{end}}

+
+ + +
+
Сертификат организации +
+ + +
+
+
+
+
+
+

Время сервера

+

Точное время нужно для подписей и обмена с НРД. В закрытом контуре внешние серверы времени недоступны — укажите сервер времени вашей сети (обычно контроллер домена или маршрутизатор).

+

{{if timeSynced}}✓ время синхронизируется{{else}}⚠ время не синхронизируется{{end}}{{if .Settings.Time.Servers}} · серверы bj: {{.Settings.Time.Servers}}{{end}}

+
+ + +
+
+
+

Сеть

+

Сервер: {{serverAddrs}}

+

Чтобы адрес bj не менялся, задайте серверу постоянный адрес — на консоли сервера (или по SSH): sudo bj-network. Утилита спросит адрес, шлюз и DNS и применит их с защитой: если связь пропадёт, через 2 минуты вернутся прежние настройки. На Astra Linux сеть настраивается средствами системы.

+
+
+

Резервное копирование

{{template "backupCard" .}}