Время сервера: часы, пояс и «Синхронизировать сейчас» в начале раздела «Сервер»
- живые часы сервера (сверка раз в 30 с), пояс, синхронизация, источник времени и расхождение с ним, сверка с часами компьютера администратора - выбор часового пояса прямо на странице (раньше — только в мастере) - «Синхронизировать сейчас»: bj-system-admin sync — разовая коррекция часов (chrony makestep, ntpd -gq, systemd-timesyncd); серверов нет или не отвечают, а свои не заданы — bj подбирает ВНИИФТРИ - мастер, шаг 12: кнопка «Синхронизировать» — та же синхронизация Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
This commit is contained in:
@@ -170,6 +170,7 @@ var templateFuncs = template.FuncMap{
|
||||
"mailtoLink": mailtoLink,
|
||||
"handbookDepos": handbookDepos,
|
||||
"timeZones": func() any { return wizTimeZones },
|
||||
"timeZoneKnown": timeZoneKnown,
|
||||
"intList": func(v ...int) []int { return v },
|
||||
"nsdSegTip": nsdSegTip,
|
||||
"devOn": authTemplateFuncs["devOn"],
|
||||
|
||||
+148
-25
@@ -6,10 +6,12 @@ package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -65,38 +67,151 @@ func registerTime(mux *http.ServeMux, rc *RuntimeConfig) {
|
||||
setupFlash(w, r, "Время уже синхронизируется.")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
|
||||
defer cancel()
|
||||
var reachable []string
|
||||
for _, srv := range ruTimeServers {
|
||||
if ntpReachable(ctx, srv) {
|
||||
reachable = append(reachable, srv)
|
||||
if len(reachable) >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(reachable) == 0 {
|
||||
setupFlash(w, r, "Из вашей сети российские серверы точного времени (ВНИИФТРИ) недоступны. "+
|
||||
"Укажите сервер времени вашей сети — обычно контроллер домена или маршрутизатор.")
|
||||
servers := pickRuTimeServers(r.Context())
|
||||
if servers == "" {
|
||||
setupFlash(w, r, ruTimeUnreachable)
|
||||
return
|
||||
}
|
||||
opctx, opcancel := context.WithTimeout(r.Context(), time.Minute)
|
||||
defer opcancel()
|
||||
servers := strings.Join(reachable, " ")
|
||||
res, err := runKeymediaOp(opctx, "system-ntp", map[string]string{"extra": servers})
|
||||
if err != nil {
|
||||
if err := applyTimeServers(r.Context(), rc, servers); 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, "Нашёл доступные российские серверы времени (ВНИИФТРИ): "+servers+
|
||||
". Синхронизирую — займёт до нескольких минут.")
|
||||
})
|
||||
|
||||
// time-sync — «Синхронизировать сейчас»: часы сразу ставятся по серверам времени (не ждать, пока
|
||||
// служба подтянет их сама). Серверов нет или не отвечают, а свои в bj не заданы — bj подбирает ВНИИФТРИ.
|
||||
mux.HandleFunc("/admin/setup/time-sync", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
msg, err := syncTimeNow(ctx)
|
||||
if err != nil && strings.TrimSpace(rc.Snapshot().Time.Servers) == "" {
|
||||
servers := pickRuTimeServers(ctx)
|
||||
if servers == "" {
|
||||
setupFlash(w, r, "Время не синхронизировано: "+err.Error()+". "+ruTimeUnreachable)
|
||||
return
|
||||
}
|
||||
if aerr := applyTimeServers(ctx, rc, servers); aerr != nil {
|
||||
setupFlash(w, r, "Время: "+aerr.Error())
|
||||
return
|
||||
}
|
||||
msg, err = syncTimeNow(ctx)
|
||||
if err == nil {
|
||||
msg += "; серверы времени bj: " + servers + " (ВНИИФТРИ)"
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Время не синхронизировано: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, msg+". Сейчас на сервере "+time.Now().Format("15:04:05")+".")
|
||||
})
|
||||
|
||||
// time-now — часы сервера для страницы: время, пояс, синхронизация, источник и его расхождение.
|
||||
mux.HandleFunc("/admin/setup/time-now", func(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now()
|
||||
abbr, off := now.Zone()
|
||||
synced, _ := timeStatus(r.Context())
|
||||
src, srcOff := timeSource(r.Context())
|
||||
jsonOut(w, map[string]any{"epoch_ms": now.UnixMilli(), "zone": serverTimeZone(), "abbr": abbr, "offset": off,
|
||||
"synced": synced, "source": src, "source_offset": srcOff})
|
||||
})
|
||||
}
|
||||
|
||||
const ruTimeUnreachable = "Из вашей сети российские серверы точного времени (ВНИИФТРИ) недоступны. " +
|
||||
"Укажите сервер времени вашей сети — обычно контроллер домена или маршрутизатор."
|
||||
|
||||
// pickRuTimeServers — до 4 отвечающих серверов ВНИИФТРИ через пробел; "" — ни один не ответил.
|
||||
func pickRuTimeServers(ctx context.Context) string {
|
||||
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
defer cancel()
|
||||
var reachable []string
|
||||
for _, srv := range ruTimeServers {
|
||||
if ntpReachable(ctx, srv) {
|
||||
reachable = append(reachable, srv)
|
||||
if len(reachable) >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(reachable, " ")
|
||||
}
|
||||
|
||||
// applyTimeServers — прописать серверы времени в службу сервера и запомнить в настройках bj.
|
||||
func applyTimeServers(ctx context.Context, rc *RuntimeConfig, servers string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
res, err := runKeymediaOp(ctx, "system-ntp", map[string]string{"extra": servers})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res["ok"] != true {
|
||||
return fmt.Errorf("%s", pickStr(asString(res["error"]), "не применено"))
|
||||
}
|
||||
return rc.UpdateTime(TimeSettings{Servers: servers, SavedAt: time.Now()})
|
||||
}
|
||||
|
||||
// syncTimeNow — разовая коррекция часов по серверам службы времени (root-скрипт bj-system-admin sync).
|
||||
func syncTimeNow(ctx context.Context) (string, error) {
|
||||
res, err := runKeymediaOp(ctx, "system-sync", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res["ok"] != true {
|
||||
return "", fmt.Errorf("%s", pickStr(asString(res["error"]), "не выполнено"))
|
||||
}
|
||||
return asString(res["output"]), nil
|
||||
}
|
||||
|
||||
// timeSource — с каким сервером сейчас сверяются часы и насколько они расходятся (chrony, ntpd,
|
||||
// systemd-timesyncd — что отвечает). Пусто — служба не сообщает.
|
||||
func timeSource(ctx context.Context) (src, offset string) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
if out, err := exec.CommandContext(ctx, "chronyc", "-n", "tracking").Output(); err == nil {
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
k, v, ok := strings.Cut(l, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(k) {
|
||||
case "Reference ID":
|
||||
if i, j := strings.Index(v, "("), strings.Index(v, ")"); i >= 0 && j > i {
|
||||
src = v[i+1 : j]
|
||||
}
|
||||
case "System time":
|
||||
f := strings.Fields(v)
|
||||
if len(f) >= 3 {
|
||||
if x, err := strconv.ParseFloat(f[0], 64); err == nil {
|
||||
offset = fmt.Sprintf("%.1f мс", x*1000)
|
||||
if f[2] == "slow" {
|
||||
offset = "−" + offset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if src != "" {
|
||||
return src, offset
|
||||
}
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "ntpq", "-pn").Output(); err == nil {
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if f := strings.Fields(l); len(f) >= 9 && strings.HasPrefix(f[0], "*") {
|
||||
return f[0][1:], f[8] + " мс"
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "timedatectl", "show-timesync", "-p", "ServerName", "--value").Output(); err == nil {
|
||||
return strings.TrimSpace(string(out)), ""
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
@@ -122,6 +237,15 @@ var wizTimeZones = []struct{ Zone, Title string }{
|
||||
{"Asia/Kamchatka", "Камчатка (UTC+12)"},
|
||||
}
|
||||
|
||||
func timeZoneKnown(z string) bool {
|
||||
for _, x := range wizTimeZones {
|
||||
if x.Zone == z {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// serverTimeZone — часовой пояс сервера (timedatectl; запасной — /etc/timezone).
|
||||
func serverTimeZone() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
@@ -244,7 +368,6 @@ func validHostname(h string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// registerHostname — POST /admin/setup/hostname: доменные имена сервера для сертификата HTTPS.
|
||||
// bj вписывает их в самоподписанный сертификат (DNS-записи заводит администратор сети отдельно).
|
||||
func registerHostname(mux *http.ServeMux, rc *RuntimeConfig) {
|
||||
|
||||
@@ -958,6 +958,60 @@
|
||||
{{/* ============ РЕЗЕРВНОЕ КОПИРОВАНИЕ ============ */}}
|
||||
<section class="settings-section" id="sec-server">
|
||||
<h1>Сервер: HTTPS, время, сеть</h1>
|
||||
{{$tz := serverTimeZone}}{{$sync := timeSynced}}
|
||||
<div class="card" id="time">
|
||||
<h2><span class="dot {{if $sync}}ok{{else}}warn{{end}}"></span>Время сервера</h2>
|
||||
<div style="display:flex;gap:28px;align-items:center;flex-wrap:wrap;margin:4px 0 2px">
|
||||
<div><div id="srv-clock" style="font-size:36px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.1">--:--:--</div>
|
||||
<div id="srv-date" class="muted" style="font-size:13.5px;margin-top:2px">{{$tz}}</div></div>
|
||||
<div style="font-size:14px;line-height:1.75;min-width:240px">
|
||||
<div id="srv-sync">{{if $sync}}<span style="color:var(--ok)">✓ время синхронизируется</span>{{else}}<span style="color:var(--warn)">⚠ время не синхронизируется</span>{{end}}</div>
|
||||
<div id="srv-src" class="muted" style="font-size:13px"></div>
|
||||
<div id="srv-diff" class="muted" style="font-size:13px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;margin-top:12px">
|
||||
<form method="post" action="/admin/setup/timezone" style="margin:0">
|
||||
<input type="hidden" name="section" value="time">
|
||||
<label style="margin:0;font-size:13px">Часовой пояс
|
||||
<select name="zone" onchange="if(confirm('Сменить часовой пояс сервера на '+this.options[this.selectedIndex].text+'? bj перезапустится (около 15 секунд).')) this.form.submit(); else this.value='{{$tz}}';" style="display:block;min-width:250px;margin-top:4px">{{if not (timeZoneKnown $tz)}}<option value="{{$tz}}" selected>{{$tz}}</option>{{end}}{{range timeZones}}<option value="{{.Zone}}" {{if eq .Zone $tz}}selected{{end}}>{{.Title}}</option>{{end}}</select></label>
|
||||
</form>
|
||||
<form method="post" action="/admin/setup/time-sync" style="margin:0" onsubmit="var b=this.querySelector('button');b.disabled=true;b.textContent='Синхронизирую… (до минуты)';">
|
||||
<input type="hidden" name="section" value="time">
|
||||
<button type="submit" class="btn">Синхронизировать сейчас</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12.5px;margin:8px 0 0">Точное время нужно для подписей и обмена с НРД (время НРД — московское). «Синхронизировать сейчас» сразу ставит часы по серверам времени; если свои серверы не заданы или не отвечают — bj подберёт российские серверы ВНИИФТРИ.</p>
|
||||
<details style="margin-top:8px" {{if not $sync}}open{{end}}><summary style="cursor:pointer;color:var(--accent);font-size:13.5px">Серверы времени{{if .Settings.Time.Servers}}: <code>{{.Settings.Time.Servers}}</code>{{end}}</summary>
|
||||
<p class="muted" style="font-size:13px;margin:6px 0">В закрытом контуре внешние серверы времени недоступны — укажите сервер времени вашей сети (обычно контроллер домена или маршрутизатор).</p>
|
||||
<form method="post" action="/admin/setup/time" style="display:flex;gap:8px;flex-wrap:wrap;align-items:end;max-width:640px">
|
||||
<input type="hidden" name="section" value="time">
|
||||
<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>
|
||||
</details>
|
||||
<script>
|
||||
(function(){ // часы сервера: сверка раз в 30 с, между сверками идут по часам браузера
|
||||
var base=null, DAYS=['вс','пн','вт','ср','чт','пт','сб'];
|
||||
function p2(n){ return ('0'+n).slice(-2); }
|
||||
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c]}); }
|
||||
function draw(){ if(!base) return;
|
||||
var t=new Date(Date.now()+base.shift+base.d.offset*1000);
|
||||
document.getElementById('srv-clock').textContent=p2(t.getUTCHours())+':'+p2(t.getUTCMinutes())+':'+p2(t.getUTCSeconds());
|
||||
var o=base.d.offset/3600; document.getElementById('srv-date').textContent=DAYS[t.getUTCDay()]+', '+p2(t.getUTCDate())+'.'+p2(t.getUTCMonth()+1)+'.'+t.getUTCFullYear()+' · '+base.d.zone+' ('+base.d.abbr+', UTC'+(o>=0?'+':'−')+Math.abs(o)+')'; }
|
||||
function sync(){ var t0=Date.now();
|
||||
fetch('/admin/setup/time-now',{cache:'no-store',credentials:'same-origin'}).then(function(r){return r.json()}).then(function(d){
|
||||
var t1=Date.now(); base={d:d, shift:d.epoch_ms-(t0+t1)/2}; draw();
|
||||
document.getElementById('srv-sync').innerHTML=d.synced?'<span style="color:var(--ok)">✓ время синхронизируется</span>':'<span style="color:var(--warn)">⚠ время не синхронизируется — нажмите «Синхронизировать сейчас»</span>';
|
||||
document.getElementById('srv-src').innerHTML=d.source?'источник: <code>'+esc(d.source)+'</code>'+(d.source_offset?' · расхождение '+esc(d.source_offset):''):'';
|
||||
var s=Math.round(base.shift/1000), df=document.getElementById('srv-diff');
|
||||
df.style.color=Math.abs(s)>=5?'var(--warn)':'';
|
||||
df.textContent=Math.abs(s)<2?'часы вашего компьютера совпадают с сервером':'часы вашего компьютера '+(s>0?'отстают от сервера':'спешат относительно сервера')+' на '+Math.abs(s)+' с'+(Math.abs(s)>=5&&d.synced?' — время сервера точное, поправьте часы компьютера':'');
|
||||
}).catch(function(){}); }
|
||||
sync(); setInterval(sync,30000); setInterval(draw,1000);
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{{$h := httpsCert}}
|
||||
<div class="card" id="sec-https">
|
||||
<h2><span class="dot {{if and .Settings.HTTPS.RedirectOn (not $h.Err)}}ok{{else}}warn{{end}}"></span>HTTPS — шифрование входа в bj</h2>
|
||||
@@ -982,15 +1036,6 @@
|
||||
{{template "workHoursForm" (whForm .Settings.WorkHours "")}}
|
||||
</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>
|
||||
|
||||
@@ -563,7 +563,7 @@
|
||||
<div class="wiz-row" style="margin-top:18px">
|
||||
<span class="wiz-row-l">Точное время</span>
|
||||
{{if timeSynced}}<span style="color:var(--ok)">✓ синхронизируется</span>{{else}}<span style="color:var(--warn)">⚠ не синхронизируется</span>
|
||||
<form method="post" action="/admin/setup/time-auto" style="display:inline;margin-left:8px">
|
||||
<form method="post" action="/admin/setup/time-sync" style="display:inline;margin-left:8px" onsubmit="var b=this.querySelector('button');b.disabled=true;b.textContent='Синхронизирую…';">
|
||||
<input type="hidden" name="return_to" value="/admin/wizard?step=12">
|
||||
<button type="submit" class="btn" style="padding:5px 12px;font-size:13px">Синхронизировать</button>
|
||||
</form>{{end}}
|
||||
|
||||
Reference in New Issue
Block a user