From bb2ff08f656e18de6ef2c5203fab8bf825eb5c8e Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:33:56 +0200 Subject: [PATCH] Fix NTP server config not reaching betterdesk-server (Fixes #223). Wire billing/NTP env from console .env into Go on Linux, Windows, and Docker; add OS NTP fallback and Commercialization settings UI to configure and restart the server. --- CHANGELOG.md | 3 + betterdesk-server/DEPLOY.md | 6 + betterdesk-server/config/config.go | 11 + betterdesk-server/main.go | 13 + betterdesk-server/timesync/service.go | 76 ++- betterdesk-server/timesync/service_test.go | 80 +++ betterdesk.ps1 | 29 ++ betterdesk.sh | 2 + docker-compose.single.yml | 4 + docker-compose.yml | 4 + docker/supervisord.conf | 2 +- web-nodejs/.env.example | 5 +- web-nodejs/lang/ar.json | 18 +- web-nodejs/lang/cs.json | 18 +- web-nodejs/lang/da.json | 18 +- web-nodejs/lang/de.json | 18 +- web-nodejs/lang/en.json | 18 +- web-nodejs/lang/es.json | 18 +- web-nodejs/lang/fi.json | 18 +- web-nodejs/lang/fr.json | 18 +- web-nodejs/lang/hi.json | 18 +- web-nodejs/lang/hu.json | 18 +- web-nodejs/lang/id.json | 18 +- web-nodejs/lang/it.json | 18 +- web-nodejs/lang/ja.json | 18 +- web-nodejs/lang/ko.json | 18 +- web-nodejs/lang/nb.json | 18 +- web-nodejs/lang/nl.json | 18 +- web-nodejs/lang/pl.json | 18 +- web-nodejs/lang/pt.json | 18 +- web-nodejs/lang/ro.json | 18 +- web-nodejs/lang/sv.json | 18 +- web-nodejs/lang/th.json | 18 +- web-nodejs/lang/tr.json | 18 +- web-nodejs/lang/uk.json | 18 +- web-nodejs/lang/vi.json | 18 +- web-nodejs/lang/zh-TW.json | 18 +- web-nodejs/lang/zh.json | 18 +- web-nodejs/public/js/commercialization.js | 59 ++- web-nodejs/routes/commercialization.routes.js | 32 ++ web-nodejs/scripts/patch-clock-i18n.js | 476 ++++++++++++++++++ .../services/billingClockConfigService.js | 147 ++++++ web-nodejs/services/updateService.js | 111 ++++ .../tests/billingClockConfigService.test.js | 58 +++ web-nodejs/views/commercialization.ejs | 25 +- 45 files changed, 1564 insertions(+), 47 deletions(-) create mode 100644 betterdesk-server/timesync/service_test.go create mode 100644 web-nodejs/scripts/patch-clock-i18n.js create mode 100644 web-nodejs/services/billingClockConfigService.js create mode 100644 web-nodejs/tests/billingClockConfigService.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f09bc6b3..7f68c806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## [Unreleased] +### Fixed +- **NTP servers for billing clock (#223)**: `betterdesk-server` now loads `NTP_SERVERS` and billing clock vars from console `.env` (Linux `EnvironmentFile` + update migration). Added `BILLING_TRUST_OS_NTP` fallback when OS time sync is healthy but public NTP is blocked. Commercialization → Settings exposes NTP configuration with Go server restart. + ### Changed - _(none yet)_ diff --git a/betterdesk-server/DEPLOY.md b/betterdesk-server/DEPLOY.md index a817b8d7..2960d751 100644 --- a/betterdesk-server/DEPLOY.md +++ b/betterdesk-server/DEPLOY.md @@ -195,6 +195,12 @@ sudo firewall-cmd --reload | `TLS_KEY` | - | TLS private key path | | `PEER_TIMEOUT_SECS` | 15 | Seconds before peer marked offline | | `TRUST_PROXY` | N | Trust X-Forwarded-For header | +| `NTP_SERVERS` | public pools | Comma-separated NTP hostnames/IPs for billing clock checks | +| `BILLING_MAX_CLOCK_SKEW_MS` | 2000 | Max allowed clock offset vs NTP (ms) | +| `BILLING_REQUIRE_SYNCED_CLOCK` | Y (Linux default) | Block billable sessions when clock unsynced | +| `BILLING_TRUST_OS_NTP` | Y on Linux, N elsewhere | Trust `timedatectl` when direct NTP queries fail | + +NTP/billing variables are stored in the web console `.env` on native installs. Linux `betterdesk-server.service` loads that file via `EnvironmentFile`. After changing NTP settings, restart **betterdesk-server** (not only the console). The panel path: **Commercialization → Settings → Server time (NTP)**. For NGINX `stream` or other UDP/TCP reverse proxy deployments, remember that signal traffic on `21116` is not HTTP and cannot carry `X-Forwarded-For`. `TRUST_PROXY` only applies to HTTP/API handlers. If many legitimate devices share the same proxy address, raise `SIGNAL_RATE_LIMIT_PER_IP` or set it to `0` only on trusted private networks. diff --git a/betterdesk-server/config/config.go b/betterdesk-server/config/config.go index e9000e1d..ba43629e 100644 --- a/betterdesk-server/config/config.go +++ b/betterdesk-server/config/config.go @@ -6,6 +6,7 @@ import ( "log" "net" "os" + "runtime" "strconv" "strings" ) @@ -138,6 +139,7 @@ type Config struct { NTPServers string // Comma-separated NTP servers BillingMaxClockSkewMS int // Max allowed clock offset vs NTP (default 2000) BillingRequireSyncedClock bool // Block billable sessions when clock unsynced + BillingTrustOSNTP bool // Trust OS NTP (timedatectl) when direct queries fail BillingRoundingMinutes int // Billable minute rounding (1, 10, 15) BillingRequireWorkReport bool // Require technician report before session close } @@ -167,6 +169,7 @@ func DefaultConfig() *Config { P2PFallbackMs: 2000, // grace period for target hole punch before relay fallback BillingMaxClockSkewMS: 2000, BillingRequireSyncedClock: true, + BillingTrustOSNTP: runtime.GOOS == "linux", BillingRoundingMinutes: 1, } } @@ -389,6 +392,14 @@ func (c *Config) LoadEnv() { c.BillingRequireSyncedClock = false } } + if v := os.Getenv("BILLING_TRUST_OS_NTP"); v != "" { + switch strings.ToUpper(v) { + case "Y", "YES", "1", "TRUE", "ON": + c.BillingTrustOSNTP = true + case "N", "NO", "0", "FALSE", "OFF": + c.BillingTrustOSNTP = false + } + } if v := os.Getenv("BILLING_ROUNDING_MINUTES"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { c.BillingRoundingMinutes = n diff --git a/betterdesk-server/main.go b/betterdesk-server/main.go index a2ef32db..b8a444b1 100644 --- a/betterdesk-server/main.go +++ b/betterdesk-server/main.go @@ -293,10 +293,23 @@ func main() { QueryTimeout: 5 * time.Second, MaxSkew: time.Duration(cfg.BillingMaxClockSkewMS) * time.Millisecond, RequireSync: cfg.BillingRequireSyncedClock, + TrustOSNTP: cfg.BillingTrustOSNTP, }) + log.Printf("[timesync] NTP servers: %v (trust OS NTP when queries fail: %v)", + cfg.GetNTPServers(), cfg.BillingTrustOSNTP) timeSyncSvc.Start(ctx) defer timeSyncSvc.Stop() + reloadHandler.OnReload(func() error { + timeSyncSvc.ApplyConfig(timesync.Config{ + Servers: cfg.GetNTPServers(), + MaxSkew: time.Duration(cfg.BillingMaxClockSkewMS) * time.Millisecond, + RequireSync: cfg.BillingRequireSyncedClock, + TrustOSNTP: cfg.BillingTrustOSNTP, + }) + return nil + }) + billingSvc := billing.NewService(database, timeSyncSvc, cfg.BillingRoundingMinutes, cfg.BillingRequireWorkReport) billingSvc.Start(ctx) diff --git a/betterdesk-server/timesync/service.go b/betterdesk-server/timesync/service.go index 00681f32..00fa6e03 100644 --- a/betterdesk-server/timesync/service.go +++ b/betterdesk-server/timesync/service.go @@ -25,11 +25,12 @@ type Status struct { // Config controls periodic NTP checks. type Config struct { - Servers []string - Interval time.Duration - QueryTimeout time.Duration - MaxSkew time.Duration - RequireSync bool + Servers []string + Interval time.Duration + QueryTimeout time.Duration + MaxSkew time.Duration + RequireSync bool + TrustOSNTP bool } // Service periodically queries NTP and exposes clock health for billing. @@ -103,18 +104,50 @@ func (s *Service) loop(ctx context.Context) { } } +// ApplyConfig hot-reloads NTP/billing clock settings (e.g. after SIGHUP). +func (s *Service) ApplyConfig(cfg Config) { + s.mu.Lock() + defer s.mu.Unlock() + if len(cfg.Servers) > 0 { + s.cfg.Servers = append([]string(nil), cfg.Servers...) + } else { + s.cfg.Servers = []string{"pool.ntp.org", "time.google.com", "time.cloudflare.com"} + } + if cfg.Interval > 0 { + s.cfg.Interval = cfg.Interval + } + if cfg.QueryTimeout > 0 { + s.cfg.QueryTimeout = cfg.QueryTimeout + } + if cfg.MaxSkew > 0 { + s.cfg.MaxSkew = cfg.MaxSkew + } + s.cfg.RequireSync = cfg.RequireSync + s.cfg.TrustOSNTP = cfg.TrustOSNTP + s.status.MaxSkewMS = s.cfg.MaxSkew.Milliseconds() + s.status.RequireSync = s.cfg.RequireSync +} + // CheckNow runs an immediate NTP check against configured servers. func (s *Service) CheckNow() Status { var ( - bestOffset time.Duration - bestServer string + bestOffset time.Duration + bestServer string bestStratum uint8 - lastErr error - found bool + lastErr error + found bool ) - for _, server := range s.cfg.Servers { - offset, stratum, err := QueryNTP(server, s.cfg.QueryTimeout) + s.mu.RLock() + servers := append([]string(nil), s.cfg.Servers...) + queryTimeout := s.cfg.QueryTimeout + maxSkew := s.cfg.MaxSkew + requireSync := s.cfg.RequireSync + trustOSNTP := s.cfg.TrustOSNTP + s.mu.RUnlock() + + for _, server := range servers { + offset, stratum, err := QueryNTP(server, queryTimeout) if err != nil { lastErr = err log.Printf("[timesync] NTP query %s failed: %v", server, err) @@ -130,14 +163,22 @@ func (s *Service) CheckNow() Status { st := Status{ LastCheckAt: time.Now().UTC(), - MaxSkewMS: s.cfg.MaxSkew.Milliseconds(), - RequireSync: s.cfg.RequireSync, + MaxSkewMS: maxSkew.Milliseconds(), + RequireSync: requireSync, } - if osSync := readOSClockSynced(); osSync != nil { + if osSync := osClockSyncedReader(); osSync != nil { st.OSClockSynced = osSync } if !found { + if trustOSNTP && st.OSClockSynced != nil && *st.OSClockSynced { + st.Synced = true + st.NTPServer = "os:systemd-timesyncd" + st.LastError = "" + s.setStatus(st) + s.persistStatus(st) + return st + } st.Synced = false if lastErr != nil { st.LastError = lastErr.Error() @@ -153,7 +194,7 @@ func (s *Service) CheckNow() Status { st.OffsetMS = offsetMS st.NTPServer = bestServer st.Stratum = bestStratum - st.Synced = absDuration(bestOffset) <= s.cfg.MaxSkew + st.Synced = absDuration(bestOffset) <= maxSkew if !st.Synced { st.LastError = "clock skew exceeds threshold" } @@ -162,7 +203,7 @@ func (s *Service) CheckNow() Status { s.persistStatus(st) if !st.Synced { log.Printf("[timesync] WARNING: clock skew %dms (max %dms) via %s", - offsetMS, s.cfg.MaxSkew.Milliseconds(), bestServer) + offsetMS, maxSkew.Milliseconds(), bestServer) } return st } @@ -219,3 +260,6 @@ func absDuration(d time.Duration) time.Duration { } return d } + +// osClockSyncedReader is overridden in tests. +var osClockSyncedReader = readOSClockSynced diff --git a/betterdesk-server/timesync/service_test.go b/betterdesk-server/timesync/service_test.go new file mode 100644 index 00000000..51fc6912 --- /dev/null +++ b/betterdesk-server/timesync/service_test.go @@ -0,0 +1,80 @@ +package timesync + +import ( + "testing" + "time" +) + +func TestApplyConfigUpdatesServers(t *testing.T) { + svc := NewService(nil, Config{ + Servers: []string{"pool.ntp.org"}, + MaxSkew: 2 * time.Second, + RequireSync: true, + }) + svc.ApplyConfig(Config{ + Servers: []string{"10.0.0.1", "ntp.example.com"}, + MaxSkew: 3 * time.Second, + RequireSync: false, + TrustOSNTP: true, + }) + + svc.mu.RLock() + defer svc.mu.RUnlock() + if len(svc.cfg.Servers) != 2 || svc.cfg.Servers[0] != "10.0.0.1" { + t.Fatalf("servers = %#v", svc.cfg.Servers) + } + if svc.cfg.MaxSkew != 3*time.Second { + t.Fatalf("max skew = %v", svc.cfg.MaxSkew) + } + if svc.cfg.RequireSync { + t.Fatal("expected RequireSync=false") + } + if !svc.cfg.TrustOSNTP { + t.Fatal("expected TrustOSNTP=true") + } +} + +func TestCheckNowTrustOSWhenNTPFails(t *testing.T) { + old := osClockSyncedReader + defer func() { osClockSyncedReader = old }() + synced := true + osClockSyncedReader = func() *bool { return &synced } + + svc := NewService(nil, Config{ + Servers: []string{"127.0.0.1:9"}, + QueryTimeout: 100 * time.Millisecond, + MaxSkew: 2 * time.Second, + TrustOSNTP: true, + }) + st := svc.CheckNow() + if !st.Synced { + t.Fatalf("expected synced via OS fallback, got %+v", st) + } + if st.NTPServer != "os:systemd-timesyncd" { + t.Fatalf("ntp_server = %q", st.NTPServer) + } + if st.LastError != "" { + t.Fatalf("last_error = %q", st.LastError) + } +} + +func TestCheckNowNoTrustOSWhenNTPFails(t *testing.T) { + old := osClockSyncedReader + defer func() { osClockSyncedReader = old }() + synced := true + osClockSyncedReader = func() *bool { return &synced } + + svc := NewService(nil, Config{ + Servers: []string{"127.0.0.1:9"}, + QueryTimeout: 100 * time.Millisecond, + MaxSkew: 2 * time.Second, + TrustOSNTP: false, + }) + st := svc.CheckNow() + if st.Synced { + t.Fatal("expected unsynced when TrustOSNTP=false") + } + if st.LastError == "" { + t.Fatal("expected last_error") + } +} diff --git a/betterdesk.ps1 b/betterdesk.ps1 index 1ee08c5b..5daea43c 100644 --- a/betterdesk.ps1 +++ b/betterdesk.ps1 @@ -2121,6 +2121,31 @@ function Setup-Services { Print-Info "Services: $script:SERVER_SERVICE, $script:CONSOLE_SERVICE" } +function Get-GoBillingEnvLauncherLines { + param([string]$EnvFilePath) + $lines = @() + $keys = @( + 'NTP_SERVERS', + 'BILLING_MAX_CLOCK_SKEW_MS', + 'BILLING_REQUIRE_SYNCED_CLOCK', + 'BILLING_TRUST_OS_NTP' + ) + if (-not $EnvFilePath -or -not (Test-Path $EnvFilePath)) { + return $lines + } + foreach ($rawLine in Get-Content $EnvFilePath) { + $line = $rawLine.Trim() + if (-not $line -or $line.StartsWith('#')) { continue } + $parts = $line -split '=', 2 + if ($parts.Count -lt 2) { continue } + $key = $parts[0].Trim() + if ($keys -notcontains $key) { continue } + $value = $parts[1].Trim() + $lines += "set `"$key=$value`"" + } + return $lines +} + function Setup-ScheduledTasks { param([string]$ServerIP) @@ -2201,6 +2226,10 @@ function Setup-ScheduledTasks { if ($adminPass) { $launcherLines += "set `"INIT_ADMIN_PASS=$adminPass`"" } # New installs default to "managed" enrollment; existing installs untouched. if ($script:FRESH_INSTALL) { $launcherLines += "set `"ENROLLMENT_MODE=managed`"" } + $billingEnvFile = Join-Path $script:CONSOLE_PATH ".env" + foreach ($billingLine in (Get-GoBillingEnvLauncherLines -EnvFilePath $billingEnvFile)) { + $launcherLines += $billingLine + } $launcherLines += "`"$serverExe`" $serverArgs" Set-Content -Path $serverLauncher -Value $launcherLines -Encoding ASCII & icacls $serverLauncher /inheritance:r /grant:r "*S-1-5-32-544:F" "*S-1-5-18:F" 2>$null | Out-Null diff --git a/betterdesk.sh b/betterdesk.sh index 40c1bbe5..ae19f8de 100755 --- a/betterdesk.sh +++ b/betterdesk.sh @@ -2749,6 +2749,7 @@ After=network.target postgresql.service Type=simple User=root WorkingDirectory=$RUSTDESK_PATH +EnvironmentFile=-$CONSOLE_PATH/.env Environment=AUTH_DB_PATH=$CONSOLE_PATH/data/auth.db Environment=MESH_ENABLED=Y $CONNECTION_MODE_ENV_BLOCK @@ -3150,6 +3151,7 @@ Wants=network-online.target Type=simple User=root WorkingDirectory=$INSTALL_DIR +$( [ -n "$CONSOLE_PATH" ] && [ -f "$CONSOLE_PATH/.env" ] && echo "EnvironmentFile=-$CONSOLE_PATH/.env" ) ExecStart=$GO_BINARY_PATH $SERVER_ARGS Restart=always RestartSec=5 diff --git a/docker-compose.single.yml b/docker-compose.single.yml index d402d7b2..0e17449a 100644 --- a/docker-compose.single.yml +++ b/docker-compose.single.yml @@ -74,6 +74,10 @@ services: # new devices); pre-existing volumes keep their current mode. Override with # "open", "managed", or "locked". - ENROLLMENT_MODE=${ENROLLMENT_MODE:-} + - NTP_SERVERS=${NTP_SERVERS:-pool.ntp.org,time.google.com,time.cloudflare.com} + - BILLING_MAX_CLOCK_SKEW_MS=${BILLING_MAX_CLOCK_SKEW_MS:-2000} + - BILLING_REQUIRE_SYNCED_CLOCK=${BILLING_REQUIRE_SYNCED_CLOCK:-1} + - BILLING_TRUST_OS_NTP=${BILLING_TRUST_OS_NTP:-Y} # Panel folders/groups for RustDesk client (SQLite — same container as console). - AUTH_DB_PATH=/app/data/auth.db # Admin credentials (first run only; existing users are not overwritten): diff --git a/docker-compose.yml b/docker-compose.yml index 4c5f4c52..9fe2f35d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,10 @@ services: # new devices); pre-existing volumes keep their current mode. Set to # "open", "managed", or "locked" to override. - ENROLLMENT_MODE=${ENROLLMENT_MODE:-} + - NTP_SERVERS=${NTP_SERVERS:-pool.ntp.org,time.google.com,time.cloudflare.com} + - BILLING_MAX_CLOCK_SKEW_MS=${BILLING_MAX_CLOCK_SKEW_MS:-2000} + - BILLING_REQUIRE_SYNCED_CLOCK=${BILLING_REQUIRE_SYNCED_CLOCK:-1} + - BILLING_TRUST_OS_NTP=${BILLING_TRUST_OS_NTP:-Y} networks: - betterdesk-net restart: unless-stopped diff --git a/docker/supervisord.conf b/docker/supervisord.conf index 572b0b91..f953d592 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -60,5 +60,5 @@ stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 -environment=SIGNAL_PORT="21116",SIGNAL_RATE_LIMIT_PER_IP="%(ENV_SIGNAL_RATE_LIMIT_PER_IP)s",ENCRYPTED_ONLY="%(ENV_ENCRYPTED_ONLY)s",DB_URL="%(ENV_DB_URL)s",AUTH_DB_PATH="%(ENV_AUTH_DB_PATH)s",RELAY_SERVERS="%(ENV_RELAY_SERVERS)s",ENROLLMENT_MODE="%(ENV_ENROLLMENT_MODE)s" +environment=SIGNAL_PORT="21116",SIGNAL_RATE_LIMIT_PER_IP="%(ENV_SIGNAL_RATE_LIMIT_PER_IP)s",ENCRYPTED_ONLY="%(ENV_ENCRYPTED_ONLY)s",DB_URL="%(ENV_DB_URL)s",AUTH_DB_PATH="%(ENV_AUTH_DB_PATH)s",RELAY_SERVERS="%(ENV_RELAY_SERVERS)s",ENROLLMENT_MODE="%(ENV_ENROLLMENT_MODE)s",NTP_SERVERS="%(ENV_NTP_SERVERS)s",BILLING_MAX_CLOCK_SKEW_MS="%(ENV_BILLING_MAX_CLOCK_SKEW_MS)s",BILLING_REQUIRE_SYNCED_CLOCK="%(ENV_BILLING_REQUIRE_SYNCED_CLOCK)s",BILLING_TRUST_OS_NTP="%(ENV_BILLING_TRUST_OS_NTP)s" priority=200 diff --git a/web-nodejs/.env.example b/web-nodejs/.env.example index 40a41ecf..982897f1 100644 --- a/web-nodejs/.env.example +++ b/web-nodejs/.env.example @@ -76,10 +76,13 @@ TRUST_PROXY=false # Console→Go API: keep on 127.0.0.1 (native install) or http://betterdesk-server:21114/api (Docker). # Do not expose the Go API port directly to WAN — use the console proxy on :21121 for RustDesk clients. -# Billing / NTP (commercialization module — merged on update, missing keys only) +# Billing / NTP (commercialization module — read by betterdesk-server Go process) +# Stored in console .env; Linux installs load this via EnvironmentFile on betterdesk-server.service. +# After changes, restart betterdesk-server (Commercialization → Settings can apply + restart). NTP_SERVERS=pool.ntp.org,time.google.com,time.cloudflare.com BILLING_MAX_CLOCK_SKEW_MS=2000 BILLING_REQUIRE_SYNCED_CLOCK=1 +BILLING_TRUST_OS_NTP=1 BILLING_ROUNDING_MINUTES=1 BILLING_REQUIRE_WORK_REPORT=1 diff --git a/web-nodejs/lang/ar.json b/web-nodejs/lang/ar.json index a4f25389..f89743e9 100644 --- a/web-nodejs/lang/ar.json +++ b/web-nodejs/lang/ar.json @@ -3835,7 +3835,23 @@ "clock": { "title": "وقت الخادم (NTP)", "check_now": "تحقق الآن", - "unsynced": "ساعة الخادم غير متزامنة — قد تُحظر الجلسات القابلة للفوترة" + "unsynced": "ساعة الخادم غير متزامنة — قد تُحظر الجلسات القابلة للفوترة", + "synced_detail": "الساعة متزامنة للفوترة", + "ntp_servers": "خوادم NTP", + "ntp_servers_hint": "أسماء مضيفة أو عناوين IP مفصولة بفواصل. تُطبَّق على خادم Go (betterdesk-server).", + "max_skew_ms": "أقصى انحراف للساعة (مللي ثانية)", + "require_sync": "اشتراط ساعة متزامنة للجلسات القابلة للفوترة", + "trust_os": "الثقة بـ NTP للنظام عند فشل الاستعلامات المباشرة", + "trust_os_hint": "استخدم حالة systemd-timesyncd/chrony عند حظر UDP NTP (DMZ/جدار ناري).", + "save": "حفظ إعدادات NTP", + "saved": "تم حفظ إعدادات الساعة. بدء إعادة تشغيل خادم Go.", + "save_restart": "حفظ إعدادات NTP وإعادة تشغيل خادم Go؟ قد تنقطع الجلسات النشطة briefly.", + "status_offset": "الانحراف", + "status_server": "مصدر NTP", + "status_os": "ساعة OS متزامنة", + "status_error": "آخر خطأ", + "os_yes": "نعم", + "os_no": "لا" }, "stats": { "active_sessions": "جلسات قابلة للفوترة نشطة" diff --git a/web-nodejs/lang/cs.json b/web-nodejs/lang/cs.json index 146d159d..eaf8fdd3 100644 --- a/web-nodejs/lang/cs.json +++ b/web-nodejs/lang/cs.json @@ -3828,7 +3828,23 @@ "clock": { "title": "Čas serveru (NTP)", "check_now": "Zkontrolovat", - "unsynced": "Hodiny serveru nejsou synchronizovány — fakturovatelné relace mohou být blokovány" + "unsynced": "Hodiny serveru nejsou synchronizovány — fakturovatelné relace mohou být blokovány", + "synced_detail": "Hodiny synchronizovány pro fakturaci", + "ntp_servers": "NTP servery", + "ntp_servers_hint": "Názvy hostitelů nebo IP oddělené čárkou. Platí pro Go server (betterdesk-server).", + "max_skew_ms": "Maximální odchylka hodin (ms)", + "require_sync": "Vyžadovat synchronizované hodiny pro fakturovatelné relace", + "trust_os": "Důvěřovat OS NTP při selhání přímých dotazů", + "trust_os_hint": "Použít stav systemd-timesyncd/chrony, když je UDP NTP blokováno (DMZ/firewall).", + "save": "Uložit nastavení NTP", + "saved": "Nastavení hodin uloženo. Restart Go serveru spuštěn.", + "save_restart": "Uložit NTP a restartovat Go server? Aktivní relace se mohou krátce odpojit.", + "status_offset": "Odchylka", + "status_server": "Zdroj NTP", + "status_os": "OS hodiny synchronizovány", + "status_error": "Poslední chyba", + "os_yes": "Ano", + "os_no": "Ne" }, "stats": { "active_sessions": "Aktivní fakturovatelné relace" diff --git a/web-nodejs/lang/da.json b/web-nodejs/lang/da.json index f910aa58..973ade9f 100644 --- a/web-nodejs/lang/da.json +++ b/web-nodejs/lang/da.json @@ -3834,7 +3834,23 @@ "clock": { "title": "Servertid (NTP)", "check_now": "Tjek nu", - "unsynced": "Serveruret er ikke synkroniseret — fakturerbare sessioner kan blive blokeret" + "unsynced": "Serveruret er ikke synkroniseret — fakturerbare sessioner kan blive blokeret", + "synced_detail": "Ur synkroniseret til fakturering", + "ntp_servers": "NTP-servere", + "ntp_servers_hint": "Kommaseparerede værtsnavne eller IP. Gælder Go-serveren (betterdesk-server).", + "max_skew_ms": "Maks. urafvigelse (ms)", + "require_sync": "Kræv synkroniseret ur for fakturerbare sessioner", + "trust_os": "Stol på OS NTP når direkte forespørgsler fejler", + "trust_os_hint": "Brug systemd-timesyncd/chrony-status når UDP NTP er blokeret (DMZ/firewall).", + "save": "Gem NTP-indstillinger", + "saved": "Urindstillinger gemt. Genstart af Go-server startet.", + "save_restart": "Gem NTP og genstart Go-serveren? Aktive sessioner kan kort afbrydes.", + "status_offset": "Afvigelse", + "status_server": "NTP-kilde", + "status_os": "OS-ur synkroniseret", + "status_error": "Seneste fejl", + "os_yes": "Ja", + "os_no": "Nej" }, "stats": { "active_sessions": "Aktive fakturerbare sessioner" diff --git a/web-nodejs/lang/de.json b/web-nodejs/lang/de.json index 443c7b95..31449920 100644 --- a/web-nodejs/lang/de.json +++ b/web-nodejs/lang/de.json @@ -3828,7 +3828,23 @@ "clock": { "title": "Serverzeit (NTP)", "check_now": "Jetzt prüfen", - "unsynced": "Serveruhr nicht synchronisiert — abrechenbare Sitzungen können blockiert werden" + "unsynced": "Serveruhr nicht synchronisiert — abrechenbare Sitzungen können blockiert werden", + "synced_detail": "Uhr für Abrechnung synchronisiert", + "ntp_servers": "NTP-Server", + "ntp_servers_hint": "Kommagetrennte Hostnamen oder IPs. Gilt für den Go-Server (betterdesk-server).", + "max_skew_ms": "Maximale Uhrabweichung (ms)", + "require_sync": "Synchronisierte Uhr für abrechenbare Sitzungen erforderlich", + "trust_os": "OS-NTP vertrauen, wenn direkte Abfragen fehlschlagen", + "trust_os_hint": "systemd-timesyncd/chrony-Status nutzen, wenn UDP-NTP blockiert ist (DMZ/Firewall).", + "save": "NTP-Einstellungen speichern", + "saved": "Uhreinstellungen gespeichert. Go-Server-Neustart gestartet.", + "save_restart": "NTP speichern und Go-Server neu starten? Aktive Sitzungen können kurz trennen.", + "status_offset": "Abweichung", + "status_server": "NTP-Quelle", + "status_os": "OS-Uhr synchronisiert", + "status_error": "Letzter Fehler", + "os_yes": "Ja", + "os_no": "Nein" }, "stats": { "active_sessions": "Aktive abrechenbare Sitzungen" diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index 43be2f14..f2a01b1b 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -3874,7 +3874,23 @@ "clock": { "title": "Server time (NTP)", "check_now": "Check now", - "unsynced": "Server clock is not synchronized — billable sessions may be blocked" + "unsynced": "Server clock is not synchronized — billable sessions may be blocked", + "synced_detail": "Clock synchronized for billing", + "ntp_servers": "NTP servers", + "ntp_servers_hint": "Comma-separated hostnames or IPs. Applied to the Go server (betterdesk-server), not only the web console.", + "max_skew_ms": "Maximum clock skew (ms)", + "require_sync": "Require synchronized clock for billable sessions", + "trust_os": "Trust OS NTP when direct queries fail", + "trust_os_hint": "Use systemd-timesyncd/chrony status when UDP NTP to public servers is blocked (DMZ/firewall).", + "save": "Save NTP settings", + "saved": "Clock settings saved. Go server restart initiated.", + "save_restart": "Save NTP settings and restart the BetterDesk Go server? Active sessions may disconnect briefly.", + "status_offset": "Offset", + "status_server": "NTP source", + "status_os": "OS clock synced", + "status_error": "Last error", + "os_yes": "Yes", + "os_no": "No" }, "stats": { "active_sessions": "Active billable sessions" diff --git a/web-nodejs/lang/es.json b/web-nodejs/lang/es.json index 47ec75ec..11d02b63 100644 --- a/web-nodejs/lang/es.json +++ b/web-nodejs/lang/es.json @@ -3828,7 +3828,23 @@ "clock": { "title": "Hora del servidor (NTP)", "check_now": "Comprobar ahora", - "unsynced": "El reloj del servidor no está sincronizado — las sesiones facturables pueden bloquearse" + "unsynced": "El reloj del servidor no está sincronizado — las sesiones facturables pueden bloquearse", + "synced_detail": "Reloj sincronizado para facturación", + "ntp_servers": "Servidores NTP", + "ntp_servers_hint": "Nombres de host o IP separados por comas. Aplica al servidor Go (betterdesk-server).", + "max_skew_ms": "Desviación máxima del reloj (ms)", + "require_sync": "Exigir reloj sincronizado para sesiones facturables", + "trust_os": "Confiar en NTP del SO si fallan las consultas directas", + "trust_os_hint": "Usar estado systemd-timesyncd/chrony cuando UDP NTP esté bloqueado (DMZ/firewall).", + "save": "Guardar ajustes NTP", + "saved": "Ajustes de reloj guardados. Reinicio del servidor Go iniciado.", + "save_restart": "¿Guardar NTP y reiniciar el servidor Go? Las sesiones activas pueden desconectarse brevemente.", + "status_offset": "Desviación", + "status_server": "Fuente NTP", + "status_os": "Reloj del SO sincronizado", + "status_error": "Último error", + "os_yes": "Sí", + "os_no": "No" }, "stats": { "active_sessions": "Sesiones facturables activas" diff --git a/web-nodejs/lang/fi.json b/web-nodejs/lang/fi.json index 8ed2b4ad..8d386296 100644 --- a/web-nodejs/lang/fi.json +++ b/web-nodejs/lang/fi.json @@ -3834,7 +3834,23 @@ "clock": { "title": "Palvelinaika (NTP)", "check_now": "Tarkista nyt", - "unsynced": "Palvelimen kello ei ole synkronoitu — laskutettavat istunnot voidaan estää" + "unsynced": "Palvelimen kello ei ole synkronoitu — laskutettavat istunnot voidaan estää", + "synced_detail": "Kello synkronoitu laskutusta varten", + "ntp_servers": "NTP-palvelimet", + "ntp_servers_hint": "Pilkuilla erotetut isäntänimet tai IP:t. Koskee Go-palvelinta (betterdesk-server).", + "max_skew_ms": "Suurin kelloero (ms)", + "require_sync": "Vaadi synkronoitu kello laskutettaville istunnoille", + "trust_os": "Luota OS NTP:hen kun suorat kyselyt epäonnistuvat", + "trust_os_hint": "Käytä systemd-timesyncd/chrony-tilaa kun UDP NTP on estetty (DMZ/palomuuri).", + "save": "Tallenna NTP-asetukset", + "saved": "Kelloasetukset tallennettu. Go-palvelimen uudelleenkäynnistys käynnistetty.", + "save_restart": "Tallenna NTP ja käynnistä Go-palvelin uudelleen? Aktiiviset istunnot voivat katketa hetkeksi.", + "status_offset": "Ero", + "status_server": "NTP-lähde", + "status_os": "OS-kello synkronoitu", + "status_error": "Viimeisin virhe", + "os_yes": "Kyllä", + "os_no": "Ei" }, "stats": { "active_sessions": "Aktiiviset laskutettavat istunnot" diff --git a/web-nodejs/lang/fr.json b/web-nodejs/lang/fr.json index 6e86905d..ce8e92aa 100644 --- a/web-nodejs/lang/fr.json +++ b/web-nodejs/lang/fr.json @@ -3832,7 +3832,23 @@ "clock": { "title": "Serverzeit (NTP)", "check_now": "Jetzt prüfen", - "unsynced": "Serveruhr nicht synchronisiert — abrechenbare Sitzungen können blockiert werden" + "unsynced": "Serveruhr nicht synchronisiert — abrechenbare Sitzungen können blockiert werden", + "synced_detail": "Horloge synchronisée pour la facturation", + "ntp_servers": "Serveurs NTP", + "ntp_servers_hint": "Noms d'hôte ou IP séparés par des virgules. Appliqué au serveur Go (betterdesk-server).", + "max_skew_ms": "Décalage horaire max. (ms)", + "require_sync": "Exiger une horloge synchronisée pour les sessions facturables", + "trust_os": "Faire confiance au NTP OS si les requêtes directes échouent", + "trust_os_hint": "Utiliser l'état systemd-timesyncd/chrony quand l'UDP NTP est bloqué (DMZ/pare-feu).", + "save": "Enregistrer les paramètres NTP", + "saved": "Paramètres d'horloge enregistrés. Redémarrage du serveur Go lancé.", + "save_restart": "Enregistrer le NTP et redémarrer le serveur Go ? Les sessions actives peuvent se déconnecter brièvement.", + "status_offset": "Décalage", + "status_server": "Source NTP", + "status_os": "Horloge OS synchronisée", + "status_error": "Dernière erreur", + "os_yes": "Oui", + "os_no": "Non" }, "stats": { "active_sessions": "Aktive abrechenbare Sitzungen" diff --git a/web-nodejs/lang/hi.json b/web-nodejs/lang/hi.json index 5edb46c1..215add1b 100644 --- a/web-nodejs/lang/hi.json +++ b/web-nodejs/lang/hi.json @@ -3834,7 +3834,23 @@ "clock": { "title": "सर्वर समय (NTP)", "check_now": "अभी जाँचें", - "unsynced": "सर्वर घड़ी सिंक नहीं है — बिल योग्य सत्र अवरुद्ध हो सकते हैं" + "unsynced": "सर्वर घड़ी सिंक नहीं है — बिल योग्य सत्र अवरुद्ध हो सकते हैं", + "synced_detail": "बिलिंग के लिए घड़ी सिंक", + "ntp_servers": "NTP सर्वर", + "ntp_servers_hint": "अल्पविराम से अलग होस्टनाम या IP। Go सर्वर (betterdesk-server) पर लागू।", + "max_skew_ms": "अधिकतम घड़ी विचलन (ms)", + "require_sync": "बिल योग्य सत्रों के लिए सिंक घड़ी आवश्यक", + "trust_os": "प्रत्यक्ष क्वेरी विफल होने पर OS NTP पर भरोसा करें", + "trust_os_hint": "UDP NTP अवरुद्ध होने पर systemd-timesyncd/chrony स्थिति का उपयोग करें।", + "save": "NTP सेटिंग्स सहेजें", + "saved": "घड़ी सेटिंग्स सहेजी गईं। Go सर्वर पुनः आरंभ शुरू।", + "save_restart": "NTP सहेजें और Go सर्वर पुनः आरंभ करें? सक्रिय सत्र briefly डिस्कनेक्ट हो सकते हैं।", + "status_offset": "ऑफ़सेट", + "status_server": "NTP स्रोत", + "status_os": "OS घड़ी सिंक", + "status_error": "अंतिम त्रुटि", + "os_yes": "हाँ", + "os_no": "नहीं" }, "stats": { "active_sessions": "सक्रिय बिल योग्य सत्र" diff --git a/web-nodejs/lang/hu.json b/web-nodejs/lang/hu.json index bd67970e..1e82fe7b 100644 --- a/web-nodejs/lang/hu.json +++ b/web-nodejs/lang/hu.json @@ -3834,7 +3834,23 @@ "clock": { "title": "Szerveridő (NTP)", "check_now": "Ellenőrzés", - "unsynced": "A szerver órája nincs szinkronban — a számlázható munkamenetek blokkolva lehetnek" + "unsynced": "A szerver órája nincs szinkronban — a számlázható munkamenetek blokkolva lehetnek", + "synced_detail": "Óra szinkronizálva számlázáshoz", + "ntp_servers": "NTP szerverek", + "ntp_servers_hint": "Vesszővel elválasztott hostnevek vagy IP-k. A Go szerverre (betterdesk-server) vonatkozik.", + "max_skew_ms": "Max. óraeltérés (ms)", + "require_sync": "Szinkronizált óra kötelező számlázható munkamenetekhez", + "trust_os": "Bízzon az OS NTP-ben, ha a közvetlen lekérdezések sikertelenek", + "trust_os_hint": "Használja a systemd-timesyncd/chrony állapotot, ha az UDP NTP blokkolva van.", + "save": "NTP beállítások mentése", + "saved": "Óra beállítások mentve. Go szerver újraindítás elindítva.", + "save_restart": "Mentse az NTP-t és indítsa újra a Go szervert? Az aktív munkamenetek rövid ideig megszakadhatnak.", + "status_offset": "Eltérés", + "status_server": "NTP forrás", + "status_os": "OS óra szinkronizálva", + "status_error": "Utolsó hiba", + "os_yes": "Igen", + "os_no": "Nem" }, "stats": { "active_sessions": "Aktív számlázható munkamenetek" diff --git a/web-nodejs/lang/id.json b/web-nodejs/lang/id.json index 2c61aae6..c8d7a79d 100644 --- a/web-nodejs/lang/id.json +++ b/web-nodejs/lang/id.json @@ -3834,7 +3834,23 @@ "clock": { "title": "Waktu server (NTP)", "check_now": "Periksa sekarang", - "unsynced": "Jam server tidak tersinkron — sesi berbayar dapat diblokir" + "unsynced": "Jam server tidak tersinkron — sesi berbayar dapat diblokir", + "synced_detail": "Jam disinkronkan untuk penagihan", + "ntp_servers": "Server NTP", + "ntp_servers_hint": "Nama host atau IP dipisah koma. Berlaku untuk server Go (betterdesk-server).", + "max_skew_ms": "Selisih jam maksimum (ms)", + "require_sync": "Wajib jam tersinkron untuk sesi berbayar", + "trust_os": "Percayai NTP OS jika kueri langsung gagal", + "trust_os_hint": "Gunakan status systemd-timesyncd/chrony saat UDP NTP diblokir (DMZ/firewall).", + "save": "Simpan pengaturan NTP", + "saved": "Pengaturan jam disimpan. Restart server Go dimulai.", + "save_restart": "Simpan NTP dan restart server Go? Sesi aktif mungkin terputus sebentar.", + "status_offset": "Offset", + "status_server": "Sumber NTP", + "status_os": "Jam OS tersinkron", + "status_error": "Kesalahan terakhir", + "os_yes": "Ya", + "os_no": "Tidak" }, "stats": { "active_sessions": "Sesi berbayar aktif" diff --git a/web-nodejs/lang/it.json b/web-nodejs/lang/it.json index 17a22a1a..6531eab8 100644 --- a/web-nodejs/lang/it.json +++ b/web-nodejs/lang/it.json @@ -3828,7 +3828,23 @@ "clock": { "title": "Ora server (NTP)", "check_now": "Controlla ora", - "unsynced": "L'orologio del server non è sincronizzato — le sessioni fatturabili possono essere bloccate" + "unsynced": "L'orologio del server non è sincronizzato — le sessioni fatturabili possono essere bloccate", + "synced_detail": "Orologio sincronizzato per la fatturazione", + "ntp_servers": "Server NTP", + "ntp_servers_hint": "Hostname o IP separati da virgola. Si applica al server Go (betterdesk-server).", + "max_skew_ms": "Scostamento orario massimo (ms)", + "require_sync": "Richiedi orologio sincronizzato per sessioni fatturabili", + "trust_os": "Affidati all'NTP del SO se le query dirette falliscono", + "trust_os_hint": "Usa lo stato systemd-timesyncd/chrony quando l'UDP NTP è bloccato (DMZ/firewall).", + "save": "Salva impostazioni NTP", + "saved": "Impostazioni orologio salvate. Riavvio server Go avviato.", + "save_restart": "Salvare NTP e riavviare il server Go? Le sessioni attive possono disconnettersi brevemente.", + "status_offset": "Scostamento", + "status_server": "Fonte NTP", + "status_os": "Orologio OS sincronizzato", + "status_error": "Ultimo errore", + "os_yes": "Sì", + "os_no": "No" }, "stats": { "active_sessions": "Sessioni fatturabili attive" diff --git a/web-nodejs/lang/ja.json b/web-nodejs/lang/ja.json index 34918cc7..52e579b8 100644 --- a/web-nodejs/lang/ja.json +++ b/web-nodejs/lang/ja.json @@ -3828,7 +3828,23 @@ "clock": { "title": "サーバー時刻 (NTP)", "check_now": "今すぐ確認", - "unsynced": "サーバー時計が同期されていません — 課金セッションがブロックされる場合があります" + "unsynced": "サーバー時計が同期されていません — 課金セッションがブロックされる場合があります", + "synced_detail": "課金用に時計が同期されています", + "ntp_servers": "NTPサーバー", + "ntp_servers_hint": "カンマ区切りのホスト名またはIP。Goサーバー(betterdesk-server)に適用されます。", + "max_skew_ms": "最大時刻ずれ (ms)", + "require_sync": "課金セッションに同期時計を必須にする", + "trust_os": "直接クエリ失敗時はOS NTPを信頼", + "trust_os_hint": "UDP NTPがブロックされている場合は systemd-timesyncd/chrony の状態を使用。", + "save": "NTP設定を保存", + "saved": "時計設定を保存しました。Goサーバーの再起動を開始しました。", + "save_restart": "NTPを保存してGoサーバーを再起動しますか?アクティブなセッションが一時的に切断される場合があります。", + "status_offset": "オフセット", + "status_server": "NTPソース", + "status_os": "OS時計同期", + "status_error": "最後のエラー", + "os_yes": "はい", + "os_no": "いいえ" }, "stats": { "active_sessions": "アクティブな課金セッション" diff --git a/web-nodejs/lang/ko.json b/web-nodejs/lang/ko.json index 2650247f..93be3726 100644 --- a/web-nodejs/lang/ko.json +++ b/web-nodejs/lang/ko.json @@ -3828,7 +3828,23 @@ "clock": { "title": "서버 시간 (NTP)", "check_now": "지금 확인", - "unsynced": "서버 시계가 동기화되지 않음 — 과금 세션이 차단될 수 있습니다" + "unsynced": "서버 시계가 동기화되지 않음 — 과금 세션이 차단될 수 있습니다", + "synced_detail": "과금을 위해 시계가 동기화됨", + "ntp_servers": "NTP 서버", + "ntp_servers_hint": "쉼표로 구분된 호스트명 또는 IP. Go 서버(betterdesk-server)에 적용됩니다.", + "max_skew_ms": "최대 시계 오차 (ms)", + "require_sync": "과금 세션에 동기화된 시계 필요", + "trust_os": "직접 쿼리 실패 시 OS NTP 신뢰", + "trust_os_hint": "UDP NTP가 차단된 경우 systemd-timesyncd/chrony 상태 사용.", + "save": "NTP 설정 저장", + "saved": "시계 설정이 저장되었습니다. Go 서버 재시작이 시작되었습니다.", + "save_restart": "NTP를 저장하고 Go 서버를 재시작하시겠습니까? 활성 세션이 잠시 끊길 수 있습니다.", + "status_offset": "오프셋", + "status_server": "NTP 소스", + "status_os": "OS 시계 동기화", + "status_error": "마지막 오류", + "os_yes": "예", + "os_no": "아니오" }, "stats": { "active_sessions": "활성 과금 세션" diff --git a/web-nodejs/lang/nb.json b/web-nodejs/lang/nb.json index e07aae1c..92aefee0 100644 --- a/web-nodejs/lang/nb.json +++ b/web-nodejs/lang/nb.json @@ -3834,7 +3834,23 @@ "clock": { "title": "Servertid (NTP)", "check_now": "Sjekk nå", - "unsynced": "Serverklokken er ikke synkronisert — fakturerbare økter kan bli blokkert" + "unsynced": "Serverklokken er ikke synkronisert — fakturerbare økter kan bli blokkert", + "synced_detail": "Klokke synkronisert for fakturering", + "ntp_servers": "NTP-servere", + "ntp_servers_hint": "Kommaseparerte vertsnavn eller IP. Gjelder Go-serveren (betterdesk-server).", + "max_skew_ms": "Maks klokkeavvik (ms)", + "require_sync": "Krev synkronisert klokke for fakturerbare økter", + "trust_os": "Stol på OS NTP når direkte forespørsler feiler", + "trust_os_hint": "Bruk systemd-timesyncd/chrony-status når UDP NTP er blokkert.", + "save": "Lagre NTP-innstillinger", + "saved": "Klokkeinnstillinger lagret. Omstart av Go-server startet.", + "save_restart": "Lagre NTP og start Go-server på nytt? Aktive økter kan kort kobles fra.", + "status_offset": "Avvik", + "status_server": "NTP-kilde", + "status_os": "OS-klokke synkronisert", + "status_error": "Siste feil", + "os_yes": "Ja", + "os_no": "Nei" }, "stats": { "active_sessions": "Aktive fakturerbare økter" diff --git a/web-nodejs/lang/nl.json b/web-nodejs/lang/nl.json index 93ab052b..8ec238d7 100644 --- a/web-nodejs/lang/nl.json +++ b/web-nodejs/lang/nl.json @@ -3828,7 +3828,23 @@ "clock": { "title": "Servertijd (NTP)", "check_now": "Nu controleren", - "unsynced": "Serverklok niet gesynchroniseerd — factureerbare sessies kunnen worden geblokkeerd" + "unsynced": "Serverklok niet gesynchroniseerd — factureerbare sessies kunnen worden geblokkeerd", + "synced_detail": "Klok gesynchroniseerd voor facturering", + "ntp_servers": "NTP-servers", + "ntp_servers_hint": "Kommagescheiden hostnamen of IP's. Geldt voor de Go-server (betterdesk-server).", + "max_skew_ms": "Maximale klokafwijking (ms)", + "require_sync": "Gesynchroniseerde klok vereisen voor factureerbare sessies", + "trust_os": "Vertrouw OS NTP wanneer directe queries falen", + "trust_os_hint": "Gebruik systemd-timesyncd/chrony-status wanneer UDP NTP geblokkeerd is.", + "save": "NTP-instellingen opslaan", + "saved": "Klokinstellingen opgeslagen. Herstart Go-server gestart.", + "save_restart": "NTP opslaan en Go-server herstarten? Actieve sessies kunnen kort verbreken.", + "status_offset": "Offset", + "status_server": "NTP-bron", + "status_os": "OS-klok gesynchroniseerd", + "status_error": "Laatste fout", + "os_yes": "Ja", + "os_no": "Nee" }, "stats": { "active_sessions": "Actieve factureerbare sessies" diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index eac809c4..b9775d74 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -3828,7 +3828,23 @@ "clock": { "title": "Czas serwera (NTP)", "check_now": "Sprawdź teraz", - "unsynced": "Zegar serwera nie jest zsynchronizowany — sesje rozliczeniowe mogą być zablokowane" + "unsynced": "Zegar serwera nie jest zsynchronizowany — sesje rozliczeniowe mogą być zablokowane", + "synced_detail": "Zegar zsynchronizowany do rozliczeń", + "ntp_servers": "Serwery NTP", + "ntp_servers_hint": "Nazwy hostów lub IP oddzielone przecinkami. Dotyczy serwera Go (betterdesk-server).", + "max_skew_ms": "Maksymalne odchylenie zegara (ms)", + "require_sync": "Wymagaj zsynchronizowanego zegara dla sesji płatnych", + "trust_os": "Ufaj NTP systemu, gdy bezpośrednie zapytania zawiodą", + "trust_os_hint": "Użyj statusu systemd-timesyncd/chrony, gdy UDP NTP jest blokowane (DMZ/firewall).", + "save": "Zapisz ustawienia NTP", + "saved": "Ustawienia zegara zapisane. Uruchomiono restart serwera Go.", + "save_restart": "Zapisać NTP i zrestartować serwer Go? Aktywne sesje mogą się chwilowo rozłączyć.", + "status_offset": "Odchylenie", + "status_server": "Źródło NTP", + "status_os": "Zegar OS zsynchronizowany", + "status_error": "Ostatni błąd", + "os_yes": "Tak", + "os_no": "Nie" }, "stats": { "active_sessions": "Aktywne sesje rozliczeniowe" diff --git a/web-nodejs/lang/pt.json b/web-nodejs/lang/pt.json index 9e4d91c3..d2619d17 100644 --- a/web-nodejs/lang/pt.json +++ b/web-nodejs/lang/pt.json @@ -3828,7 +3828,23 @@ "clock": { "title": "Hora do servidor (NTP)", "check_now": "Verificar agora", - "unsynced": "Relógio do servidor não sincronizado — sessões faturáveis podem ser bloqueadas" + "unsynced": "Relógio do servidor não sincronizado — sessões faturáveis podem ser bloqueadas", + "synced_detail": "Relógio sincronizado para faturação", + "ntp_servers": "Servidores NTP", + "ntp_servers_hint": "Nomes de host ou IP separados por vírgula. Aplica-se ao servidor Go (betterdesk-server).", + "max_skew_ms": "Desvio máximo do relógio (ms)", + "require_sync": "Exigir relógio sincronizado para sessões faturáveis", + "trust_os": "Confiar no NTP do SO quando consultas diretas falharem", + "trust_os_hint": "Usar estado systemd-timesyncd/chrony quando UDP NTP estiver bloqueado.", + "save": "Guardar definições NTP", + "saved": "Definições de relógio guardadas. Reinício do servidor Go iniciado.", + "save_restart": "Guardar NTP e reiniciar o servidor Go? Sessões ativas podem desligar-se brevemente.", + "status_offset": "Desvio", + "status_server": "Fonte NTP", + "status_os": "Relógio do SO sincronizado", + "status_error": "Último erro", + "os_yes": "Sim", + "os_no": "Não" }, "stats": { "active_sessions": "Sessões faturáveis ativas" diff --git a/web-nodejs/lang/ro.json b/web-nodejs/lang/ro.json index 7894334a..0f623773 100644 --- a/web-nodejs/lang/ro.json +++ b/web-nodejs/lang/ro.json @@ -3834,7 +3834,23 @@ "clock": { "title": "Ora serverului (NTP)", "check_now": "Verifică acum", - "unsynced": "Ceasul serverului nu este sincronizat — sesiunile facturabile pot fi blocate" + "unsynced": "Ceasul serverului nu este sincronizat — sesiunile facturabile pot fi blocate", + "synced_detail": "Ceas sincronizat pentru facturare", + "ntp_servers": "Servere NTP", + "ntp_servers_hint": "Nume gazdă sau IP separate prin virgulă. Se aplică serverului Go (betterdesk-server).", + "max_skew_ms": "Abatere maximă ceas (ms)", + "require_sync": "Cere ceas sincronizat pentru sesiuni facturabile", + "trust_os": "Ai încredere în NTP OS când interogările directe eșuează", + "trust_os_hint": "Folosește starea systemd-timesyncd/chrony când UDP NTP este blocat.", + "save": "Salvează setările NTP", + "saved": "Setările ceasului salvate. Repornire server Go inițiată.", + "save_restart": "Salvezi NTP și repornești serverul Go? Sesiunile active se pot deconecta scurt.", + "status_offset": "Abatere", + "status_server": "Sursă NTP", + "status_os": "Ceas OS sincronizat", + "status_error": "Ultima eroare", + "os_yes": "Da", + "os_no": "Nu" }, "stats": { "active_sessions": "Sesiuni facturabile active" diff --git a/web-nodejs/lang/sv.json b/web-nodejs/lang/sv.json index 38394822..80a68e0a 100644 --- a/web-nodejs/lang/sv.json +++ b/web-nodejs/lang/sv.json @@ -3834,7 +3834,23 @@ "clock": { "title": "Servertid (NTP)", "check_now": "Kontrollera nu", - "unsynced": "Serverklockan är inte synkroniserad — fakturerbara sessioner kan blockeras" + "unsynced": "Serverklockan är inte synkroniserad — fakturerbara sessioner kan blockeras", + "synced_detail": "Klocka synkroniserad för fakturering", + "ntp_servers": "NTP-servrar", + "ntp_servers_hint": "Kommaseparerade värdnamn eller IP. Gäller Go-servern (betterdesk-server).", + "max_skew_ms": "Max klockavvikelse (ms)", + "require_sync": "Kräv synkad klocka för fakturerbara sessioner", + "trust_os": "Lita på OS NTP när direkta frågor misslyckas", + "trust_os_hint": "Använd systemd-timesyncd/chrony-status när UDP NTP blockeras.", + "save": "Spara NTP-inställningar", + "saved": "Klockinställningar sparade. Omstart av Go-server startad.", + "save_restart": "Spara NTP och starta om Go-servern? Aktiva sessioner kan kopplas från kort.", + "status_offset": "Avvikelse", + "status_server": "NTP-källa", + "status_os": "OS-klocka synkad", + "status_error": "Senaste fel", + "os_yes": "Ja", + "os_no": "Nej" }, "stats": { "active_sessions": "Aktiva fakturerbara sessioner" diff --git a/web-nodejs/lang/th.json b/web-nodejs/lang/th.json index 5773c56c..6567277a 100644 --- a/web-nodejs/lang/th.json +++ b/web-nodejs/lang/th.json @@ -3834,7 +3834,23 @@ "clock": { "title": "เวลาเซิร์ฟเวอร์ (NTP)", "check_now": "ตรวจสอบตอนนี้", - "unsynced": "นาฬิกาเซิร์ฟเวอร์ไม่ซิงค์ — เซสชันที่เรียกเก็บเงินอาจถูกบล็อก" + "unsynced": "นาฬิกาเซิร์ฟเวอร์ไม่ซิงค์ — เซสชันที่เรียกเก็บเงินอาจถูกบล็อก", + "synced_detail": "นาฬิกาซิงค์สำหรับการเรียกเก็บเงิน", + "ntp_servers": "เซิร์ฟเวอร์ NTP", + "ntp_servers_hint": "ชื่อโฮสต์หรือ IP คั่นด้วยจุลภาค ใช้กับเซิร์ฟเวอร์ Go (betterdesk-server)", + "max_skew_ms": "ความคลาดเคลื่อนสูงสุดของนาฬิกา (ms)", + "require_sync": "ต้องมีนาฬิกาซิงค์สำหรับเซสชันที่เรียกเก็บเงิน", + "trust_os": "เชื่อถือ NTP ของ OS เมื่อการสอบถามโดยตรงล้มเหลว", + "trust_os_hint": "ใช้สถานะ systemd-timesyncd/chrony เมื่อ UDP NTP ถูกบล็อก", + "save": "บันทึกการตั้งค่า NTP", + "saved": "บันทึกการตั้งค่านาฬิกาแล้ว เริ่มรีสตาร์ทเซิร์ฟเวอร์ Go", + "save_restart": "บันทึก NTP และรีสตาร์ทเซิร์ฟเวอร์ Go? เซสชันที่ใช้งานอาจหลุดชั่วคราว", + "status_offset": "ออฟเซ็ต", + "status_server": "แหล่ง NTP", + "status_os": "นาฬิกา OS ซิงค์", + "status_error": "ข้อผิดพลาดล่าสุด", + "os_yes": "ใช่", + "os_no": "ไม่" }, "stats": { "active_sessions": "เซสชันที่เรียกเก็บเงินที่ใช้งานอยู่" diff --git a/web-nodejs/lang/tr.json b/web-nodejs/lang/tr.json index dc06e05e..4ae0b835 100644 --- a/web-nodejs/lang/tr.json +++ b/web-nodejs/lang/tr.json @@ -3834,7 +3834,23 @@ "clock": { "title": "Sunucu saati (NTP)", "check_now": "Şimdi kontrol et", - "unsynced": "Sunucu saati senkronize değil — faturalandırılabilir oturumlar engellenebilir" + "unsynced": "Sunucu saati senkronize değil — faturalandırılabilir oturumlar engellenebilir", + "synced_detail": "Faturalandırma için saat senkronize", + "ntp_servers": "NTP sunucuları", + "ntp_servers_hint": "Virgülle ayrılmış ana bilgisayar adları veya IP. Go sunucusuna (betterdesk-server) uygulanır.", + "max_skew_ms": "Maksimum saat sapması (ms)", + "require_sync": "Faturalandırılabilir oturumlar için senkron saat gerekli", + "trust_os": "Doğrudan sorgular başarısız olursa OS NTP'ye güven", + "trust_os_hint": "UDP NTP engellendiğinde systemd-timesyncd/chrony durumunu kullanın.", + "save": "NTP ayarlarını kaydet", + "saved": "Saat ayarları kaydedildi. Go sunucusu yeniden başlatılıyor.", + "save_restart": "NTP kaydedilsin ve Go sunucusu yeniden başlatılsın mı? Aktif oturumlar kısa süre kesilebilir.", + "status_offset": "Sapma", + "status_server": "NTP kaynağı", + "status_os": "OS saati senkron", + "status_error": "Son hata", + "os_yes": "Evet", + "os_no": "Hayır" }, "stats": { "active_sessions": "Aktif faturalandırılabilir oturumlar" diff --git a/web-nodejs/lang/uk.json b/web-nodejs/lang/uk.json index e8846a10..814f021e 100644 --- a/web-nodejs/lang/uk.json +++ b/web-nodejs/lang/uk.json @@ -3834,7 +3834,23 @@ "clock": { "title": "Час сервера (NTP)", "check_now": "Перевірити зараз", - "unsynced": "Годинник сервера не синхронізовано — платні сесії можуть бути заблоковані" + "unsynced": "Годинник сервера не синхронізовано — платні сесії можуть бути заблоковані", + "synced_detail": "Годинник синхронізовано для білінгу", + "ntp_servers": "NTP-сервери", + "ntp_servers_hint": "Імена хостів або IP через кому. Застосовується до Go-сервера (betterdesk-server).", + "max_skew_ms": "Максимальне відхилення годинника (мс)", + "require_sync": "Вимагати синхронізований годинник для платних сесій", + "trust_os": "Довіряти OS NTP, якщо прямі запити не вдаються", + "trust_os_hint": "Використовуйте статус systemd-timesyncd/chrony, коли UDP NTP заблоковано.", + "save": "Зберегти налаштування NTP", + "saved": "Налаштування годинника збережено. Перезапуск Go-сервера розпочато.", + "save_restart": "Зберегти NTP і перезапустити Go-сервер? Активні сесії можуть коротко відключитися.", + "status_offset": "Відхилення", + "status_server": "Джерело NTP", + "status_os": "Годинник OS синхронізовано", + "status_error": "Остання помилка", + "os_yes": "Так", + "os_no": "Ні" }, "stats": { "active_sessions": "Активні платні сесії" diff --git a/web-nodejs/lang/vi.json b/web-nodejs/lang/vi.json index f53e7caf..e34c94f2 100644 --- a/web-nodejs/lang/vi.json +++ b/web-nodejs/lang/vi.json @@ -3834,7 +3834,23 @@ "clock": { "title": "Giờ máy chủ (NTP)", "check_now": "Kiểm tra ngay", - "unsynced": "Đồng hồ máy chủ chưa đồng bộ — phiên tính phí có thể bị chặn" + "unsynced": "Đồng hồ máy chủ chưa đồng bộ — phiên tính phí có thể bị chặn", + "synced_detail": "Đồng hồ đã đồng bộ cho tính phí", + "ntp_servers": "Máy chủ NTP", + "ntp_servers_hint": "Tên máy chủ hoặc IP cách nhau bằng dấu phẩy. Áp dụng cho máy chủ Go (betterdesk-server).", + "max_skew_ms": "Độ lệch đồng hồ tối đa (ms)", + "require_sync": "Yêu cầu đồng hồ đồng bộ cho phiên tính phí", + "trust_os": "Tin cậy NTP OS khi truy vấn trực tiếp thất bại", + "trust_os_hint": "Dùng trạng thái systemd-timesyncd/chrony khi UDP NTP bị chặn.", + "save": "Lưu cài đặt NTP", + "saved": "Đã lưu cài đặt đồng hồ. Đang khởi động lại máy chủ Go.", + "save_restart": "Lưu NTP và khởi động lại máy chủ Go? Phiên đang hoạt động có thể ngắt tạm thời.", + "status_offset": "Độ lệch", + "status_server": "Nguồn NTP", + "status_os": "Đồng hồ OS đã đồng bộ", + "status_error": "Lỗi cuối", + "os_yes": "Có", + "os_no": "Không" }, "stats": { "active_sessions": "Phiên tính phí đang hoạt động" diff --git a/web-nodejs/lang/zh-TW.json b/web-nodejs/lang/zh-TW.json index da178ff6..96669e9f 100644 --- a/web-nodejs/lang/zh-TW.json +++ b/web-nodejs/lang/zh-TW.json @@ -3832,7 +3832,23 @@ "clock": { "title": "服務器時間 (NTP)", "check_now": "立即檢查", - "unsynced": "服務器時鐘未同步 — 可計費會話可能被阻止" + "unsynced": "服務器時鐘未同步 — 可計費會話可能被阻止", + "synced_detail": "時鐘已同步,可用於計費", + "ntp_servers": "NTP 伺服器", + "ntp_servers_hint": "逗號分隔的主機名稱或 IP。套用至 Go 伺服器(betterdesk-server)。", + "max_skew_ms": "最大時鐘偏差 (ms)", + "require_sync": "可計費工作階段要求時鐘同步", + "trust_os": "直接查詢失敗時信任 OS NTP", + "trust_os_hint": "UDP NTP 被阻擋時使用 systemd-timesyncd/chrony 狀態。", + "save": "儲存 NTP 設定", + "saved": "時鐘設定已儲存。已啟動 Go 伺服器重新啟動。", + "save_restart": "儲存 NTP 並重新啟動 Go 伺服器?使用中工作階段可能短暫中斷。", + "status_offset": "偏移", + "status_server": "NTP 來源", + "status_os": "OS 時鐘已同步", + "status_error": "最後錯誤", + "os_yes": "是", + "os_no": "否" }, "stats": { "active_sessions": "活動可計費會話" diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json index d52a4a1a..6b91c727 100644 --- a/web-nodejs/lang/zh.json +++ b/web-nodejs/lang/zh.json @@ -3828,7 +3828,23 @@ "clock": { "title": "服务器时间 (NTP)", "check_now": "立即检查", - "unsynced": "服务器时钟未同步 — 可计费会话可能被阻止" + "unsynced": "服务器时钟未同步 — 可计费会话可能被阻止", + "synced_detail": "时钟已同步,可用于计费", + "ntp_servers": "NTP 服务器", + "ntp_servers_hint": "逗号分隔的主机名或 IP。应用于 Go 服务器(betterdesk-server)。", + "max_skew_ms": "最大时钟偏差 (ms)", + "require_sync": "可计费会话要求时钟同步", + "trust_os": "直接查询失败时信任 OS NTP", + "trust_os_hint": "UDP NTP 被阻止时使用 systemd-timesyncd/chrony 状态。", + "save": "保存 NTP 设置", + "saved": "时钟设置已保存。已启动 Go 服务器重启。", + "save_restart": "保存 NTP 并重启 Go 服务器?活动会话可能短暂断开。", + "status_offset": "偏移", + "status_server": "NTP 来源", + "status_os": "OS 时钟已同步", + "status_error": "最后错误", + "os_yes": "是", + "os_no": "否" }, "stats": { "active_sessions": "活动可计费会话" diff --git a/web-nodejs/public/js/commercialization.js b/web-nodejs/public/js/commercialization.js index ce308f4d..d5f764a8 100644 --- a/web-nodejs/public/js/commercialization.js +++ b/web-nodejs/public/js/commercialization.js @@ -112,20 +112,51 @@ const statusEl = document.getElementById('clock-status'); const offsetEl = document.getElementById('clock-offset'); const detail = document.getElementById('settings-clock-detail'); + const ntpServerEl = document.getElementById('clock-ntp-server'); + const osSyncEl = document.getElementById('clock-os-sync'); + const lastErrorEl = document.getElementById('clock-last-error'); const synced = !!st.synced; if (statusEl) statusEl.textContent = synced ? 'OK' : 'WARN'; const offset = `${st.offset_ms || 0} ms`; if (offsetEl) offsetEl.textContent = offset; - if (detail) detail.textContent = `${synced ? 'Synced' : 'Not synced'} · offset ${offset}`; + if (ntpServerEl) ntpServerEl.textContent = st.ntp_server || '—'; + if (osSyncEl) { + if (st.os_clock_synced === true) osSyncEl.textContent = t('commercialization.clock.os_yes', 'Yes'); + else if (st.os_clock_synced === false) osSyncEl.textContent = t('commercialization.clock.os_no', 'No'); + else osSyncEl.textContent = '—'; + } + if (lastErrorEl) lastErrorEl.textContent = st.last_error || '—'; + if (detail) { + detail.textContent = synced + ? t('commercialization.clock.synced_detail', 'Clock synchronized for billing') + : t('commercialization.clock.unsynced', 'Server clock is not synchronized — billable sessions may be blocked'); + } if (banner) { banner.classList.toggle('hidden', synced); - banner.textContent = synced ? '' : t('commercialization.clock.unsynced', 'Clock not synchronized'); + banner.textContent = synced ? '' : t('commercialization.clock.unsynced', 'Server clock is not synchronized — billable sessions may be blocked'); } } catch (e) { console.warn('[commercialization] timesync', e); } } + async function loadClockSettings() { + try { + const data = await api('/api/panel/billing/clock/settings'); + const settings = data.settings || {}; + const serversEl = document.getElementById('clock-ntp-servers'); + const skewEl = document.getElementById('clock-max-skew'); + const requireEl = document.getElementById('clock-require-sync'); + const trustEl = document.getElementById('clock-trust-os'); + if (serversEl) serversEl.value = settings.ntp_servers || ''; + if (skewEl) skewEl.value = settings.max_skew_ms || 2000; + if (requireEl) requireEl.checked = settings.require_synced_clock !== false; + if (trustEl) trustEl.checked = settings.trust_os_ntp !== false; + } catch (e) { + console.warn('[commercialization] clock settings', e); + } + } + async function loadPackages() { const tbody = document.querySelector('#packages-table tbody'); if (!tbody) return []; @@ -341,6 +372,29 @@ await loadTimesync(); }); + document.getElementById('btn-save-clock-settings')?.addEventListener('click', async () => { + const ok = window.confirm(t( + 'commercialization.clock.save_restart', + 'Save NTP settings and restart the BetterDesk Go server? Active sessions may disconnect briefly.' + )); + if (!ok) return; + try { + await api('/api/panel/billing/clock/settings', { + method: 'PUT', + body: JSON.stringify({ + ntp_servers: document.getElementById('clock-ntp-servers')?.value || '', + max_skew_ms: Number(document.getElementById('clock-max-skew')?.value || 2000), + require_synced_clock: document.getElementById('clock-require-sync')?.checked !== false, + trust_os_ntp: document.getElementById('clock-trust-os')?.checked !== false, + }) + }); + alert(t('commercialization.clock.saved', 'Clock settings saved. Go server restart initiated.')); + await loadTimesync(); + } catch (e) { + alert(e.message); + } + }); + document.getElementById('btn-new-package')?.addEventListener('click', () => { openPackageModal().catch((e) => alert(e.message)); }); @@ -417,6 +471,7 @@ } if (tab === 'settings') { loadEmailNotificationSettings().catch(console.warn); + loadClockSettings().catch(console.warn); } if (tab === 'overview' || tab === 'sessions') { loadSessions().catch(console.warn); diff --git a/web-nodejs/routes/commercialization.routes.js b/web-nodejs/routes/commercialization.routes.js index 18b90f55..efd38eb1 100644 --- a/web-nodejs/routes/commercialization.routes.js +++ b/web-nodejs/routes/commercialization.routes.js @@ -11,6 +11,7 @@ const { CONFIG_KEY, } = require('../services/helpRequestEmailService'); const { getSmtpSettings } = require('../lib/smtpSettingsHandlers'); +const billingClockConfig = require('../services/billingClockConfigService'); router.get('/commercialization', requireAuth, requirePermission('billing.view'), (req, res) => { const validTabs = ['overview', 'packages', 'sessions', 'reports', 'settings']; @@ -39,6 +40,37 @@ router.post('/api/panel/billing/timesync/check', requireAuth, requirePermission( proxyToGo(apiClient, req, res, 'POST', '/timesync/check'); }); +router.get('/api/panel/billing/clock/settings', requireAuth, requirePermission('billing.view'), (req, res) => { + try { + res.json({ success: true, settings: billingClockConfig.getClockSettings() }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +router.put('/api/panel/billing/clock/settings', requireAuth, requirePermission('server.config'), async (req, res) => { + try { + const result = await billingClockConfig.saveClockSettings(req.body || {}, { restart: true }); + try { + await db.logAction( + req.session.userId, + 'billing_clock_settings_updated', + 'Updated billing/NTP clock settings', + req.ip + ); + } catch (_) {} + res.json({ + success: true, + settings: result.settings, + serviceConfig: result.serviceConfig, + restart: result.restart, + }); + } catch (err) { + const code = ['invalid_ntp_servers', 'invalid_max_skew'].includes(err.message) ? 400 : 500; + res.status(code).json({ success: false, error: err.message }); + } +}); + router.get('/api/panel/billing/packages', requireAuth, requirePermission('billing.view'), (req, res) => { proxyToGo(apiClient, req, res, 'GET', '/billing/packages'); }); diff --git a/web-nodejs/scripts/patch-clock-i18n.js b/web-nodejs/scripts/patch-clock-i18n.js new file mode 100644 index 00000000..a8c8867e --- /dev/null +++ b/web-nodejs/scripts/patch-clock-i18n.js @@ -0,0 +1,476 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const langDir = path.join(__dirname, '..', 'lang'); +const locales = fs.readdirSync(langDir).filter((f) => f.endsWith('.json')); + +const patches = { + ar: { + synced_detail: 'الساعة متزامنة للفوترة', + ntp_servers: 'خوادم NTP', + ntp_servers_hint: 'أسماء مضيفة أو عناوين IP مفصولة بفواصل. تُطبَّق على خادم Go (betterdesk-server).', + max_skew_ms: 'أقصى انحراف للساعة (مللي ثانية)', + require_sync: 'اشتراط ساعة متزامنة للجلسات القابلة للفوترة', + trust_os: 'الثقة بـ NTP للنظام عند فشل الاستعلامات المباشرة', + trust_os_hint: 'استخدم حالة systemd-timesyncd/chrony عند حظر UDP NTP (DMZ/جدار ناري).', + save: 'حفظ إعدادات NTP', + saved: 'تم حفظ إعدادات الساعة. بدء إعادة تشغيل خادم Go.', + save_restart: 'حفظ إعدادات NTP وإعادة تشغيل خادم Go؟ قد تنقطع الجلسات النشطة briefly.', + status_offset: 'الانحراف', + status_server: 'مصدر NTP', + status_os: 'ساعة OS متزامنة', + status_error: 'آخر خطأ', + os_yes: 'نعم', + os_no: 'لا', + }, + cs: { + synced_detail: 'Hodiny synchronizovány pro fakturaci', + ntp_servers: 'NTP servery', + ntp_servers_hint: 'Názvy hostitelů nebo IP oddělené čárkou. Platí pro Go server (betterdesk-server).', + max_skew_ms: 'Maximální odchylka hodin (ms)', + require_sync: 'Vyžadovat synchronizované hodiny pro fakturovatelné relace', + trust_os: 'Důvěřovat OS NTP při selhání přímých dotazů', + trust_os_hint: 'Použít stav systemd-timesyncd/chrony, když je UDP NTP blokováno (DMZ/firewall).', + save: 'Uložit nastavení NTP', + saved: 'Nastavení hodin uloženo. Restart Go serveru spuštěn.', + save_restart: 'Uložit NTP a restartovat Go server? Aktivní relace se mohou krátce odpojit.', + status_offset: 'Odchylka', + status_server: 'Zdroj NTP', + status_os: 'OS hodiny synchronizovány', + status_error: 'Poslední chyba', + os_yes: 'Ano', + os_no: 'Ne', + }, + da: { + synced_detail: 'Ur synkroniseret til fakturering', + ntp_servers: 'NTP-servere', + ntp_servers_hint: 'Kommaseparerede værtsnavne eller IP. Gælder Go-serveren (betterdesk-server).', + max_skew_ms: 'Maks. urafvigelse (ms)', + require_sync: 'Kræv synkroniseret ur for fakturerbare sessioner', + trust_os: 'Stol på OS NTP når direkte forespørgsler fejler', + trust_os_hint: 'Brug systemd-timesyncd/chrony-status når UDP NTP er blokeret (DMZ/firewall).', + save: 'Gem NTP-indstillinger', + saved: 'Urindstillinger gemt. Genstart af Go-server startet.', + save_restart: 'Gem NTP og genstart Go-serveren? Aktive sessioner kan kort afbrydes.', + status_offset: 'Afvigelse', + status_server: 'NTP-kilde', + status_os: 'OS-ur synkroniseret', + status_error: 'Seneste fejl', + os_yes: 'Ja', + os_no: 'Nej', + }, + de: { + synced_detail: 'Uhr für Abrechnung synchronisiert', + ntp_servers: 'NTP-Server', + ntp_servers_hint: 'Kommagetrennte Hostnamen oder IPs. Gilt für den Go-Server (betterdesk-server).', + max_skew_ms: 'Maximale Uhrabweichung (ms)', + require_sync: 'Synchronisierte Uhr für abrechenbare Sitzungen erforderlich', + trust_os: 'OS-NTP vertrauen, wenn direkte Abfragen fehlschlagen', + trust_os_hint: 'systemd-timesyncd/chrony-Status nutzen, wenn UDP-NTP blockiert ist (DMZ/Firewall).', + save: 'NTP-Einstellungen speichern', + saved: 'Uhreinstellungen gespeichert. Go-Server-Neustart gestartet.', + save_restart: 'NTP speichern und Go-Server neu starten? Aktive Sitzungen können kurz trennen.', + status_offset: 'Abweichung', + status_server: 'NTP-Quelle', + status_os: 'OS-Uhr synchronisiert', + status_error: 'Letzter Fehler', + os_yes: 'Ja', + os_no: 'Nein', + }, + es: { + synced_detail: 'Reloj sincronizado para facturación', + ntp_servers: 'Servidores NTP', + ntp_servers_hint: 'Nombres de host o IP separados por comas. Aplica al servidor Go (betterdesk-server).', + max_skew_ms: 'Desviación máxima del reloj (ms)', + require_sync: 'Exigir reloj sincronizado para sesiones facturables', + trust_os: 'Confiar en NTP del SO si fallan las consultas directas', + trust_os_hint: 'Usar estado systemd-timesyncd/chrony cuando UDP NTP esté bloqueado (DMZ/firewall).', + save: 'Guardar ajustes NTP', + saved: 'Ajustes de reloj guardados. Reinicio del servidor Go iniciado.', + save_restart: '¿Guardar NTP y reiniciar el servidor Go? Las sesiones activas pueden desconectarse brevemente.', + status_offset: 'Desviación', + status_server: 'Fuente NTP', + status_os: 'Reloj del SO sincronizado', + status_error: 'Último error', + os_yes: 'Sí', + os_no: 'No', + }, + fi: { + synced_detail: 'Kello synkronoitu laskutusta varten', + ntp_servers: 'NTP-palvelimet', + ntp_servers_hint: 'Pilkuilla erotetut isäntänimet tai IP:t. Koskee Go-palvelinta (betterdesk-server).', + max_skew_ms: 'Suurin kelloero (ms)', + require_sync: 'Vaadi synkronoitu kello laskutettaville istunnoille', + trust_os: 'Luota OS NTP:hen kun suorat kyselyt epäonnistuvat', + trust_os_hint: 'Käytä systemd-timesyncd/chrony-tilaa kun UDP NTP on estetty (DMZ/palomuuri).', + save: 'Tallenna NTP-asetukset', + saved: 'Kelloasetukset tallennettu. Go-palvelimen uudelleenkäynnistys käynnistetty.', + save_restart: 'Tallenna NTP ja käynnistä Go-palvelin uudelleen? Aktiiviset istunnot voivat katketa hetkeksi.', + status_offset: 'Ero', + status_server: 'NTP-lähde', + status_os: 'OS-kello synkronoitu', + status_error: 'Viimeisin virhe', + os_yes: 'Kyllä', + os_no: 'Ei', + }, + fr: { + synced_detail: 'Horloge synchronisée pour la facturation', + ntp_servers: 'Serveurs NTP', + ntp_servers_hint: 'Noms d\'hôte ou IP séparés par des virgules. Appliqué au serveur Go (betterdesk-server).', + max_skew_ms: 'Décalage horaire max. (ms)', + require_sync: 'Exiger une horloge synchronisée pour les sessions facturables', + trust_os: 'Faire confiance au NTP OS si les requêtes directes échouent', + trust_os_hint: 'Utiliser l\'état systemd-timesyncd/chrony quand l\'UDP NTP est bloqué (DMZ/pare-feu).', + save: 'Enregistrer les paramètres NTP', + saved: 'Paramètres d\'horloge enregistrés. Redémarrage du serveur Go lancé.', + save_restart: 'Enregistrer le NTP et redémarrer le serveur Go ? Les sessions actives peuvent se déconnecter brièvement.', + status_offset: 'Décalage', + status_server: 'Source NTP', + status_os: 'Horloge OS synchronisée', + status_error: 'Dernière erreur', + os_yes: 'Oui', + os_no: 'Non', + }, + hi: { + synced_detail: 'बिलिंग के लिए घड़ी सिंक', + ntp_servers: 'NTP सर्वर', + ntp_servers_hint: 'अल्पविराम से अलग होस्टनाम या IP। Go सर्वर (betterdesk-server) पर लागू।', + max_skew_ms: 'अधिकतम घड़ी विचलन (ms)', + require_sync: 'बिल योग्य सत्रों के लिए सिंक घड़ी आवश्यक', + trust_os: 'प्रत्यक्ष क्वेरी विफल होने पर OS NTP पर भरोसा करें', + trust_os_hint: 'UDP NTP अवरुद्ध होने पर systemd-timesyncd/chrony स्थिति का उपयोग करें।', + save: 'NTP सेटिंग्स सहेजें', + saved: 'घड़ी सेटिंग्स सहेजी गईं। Go सर्वर पुनः आरंभ शुरू।', + save_restart: 'NTP सहेजें और Go सर्वर पुनः आरंभ करें? सक्रिय सत्र briefly डिस्कनेक्ट हो सकते हैं।', + status_offset: 'ऑफ़सेट', + status_server: 'NTP स्रोत', + status_os: 'OS घड़ी सिंक', + status_error: 'अंतिम त्रुटि', + os_yes: 'हाँ', + os_no: 'नहीं', + }, + hu: { + synced_detail: 'Óra szinkronizálva számlázáshoz', + ntp_servers: 'NTP szerverek', + ntp_servers_hint: 'Vesszővel elválasztott hostnevek vagy IP-k. A Go szerverre (betterdesk-server) vonatkozik.', + max_skew_ms: 'Max. óraeltérés (ms)', + require_sync: 'Szinkronizált óra kötelező számlázható munkamenetekhez', + trust_os: 'Bízzon az OS NTP-ben, ha a közvetlen lekérdezések sikertelenek', + trust_os_hint: 'Használja a systemd-timesyncd/chrony állapotot, ha az UDP NTP blokkolva van.', + save: 'NTP beállítások mentése', + saved: 'Óra beállítások mentve. Go szerver újraindítás elindítva.', + save_restart: 'Mentse az NTP-t és indítsa újra a Go szervert? Az aktív munkamenetek rövid ideig megszakadhatnak.', + status_offset: 'Eltérés', + status_server: 'NTP forrás', + status_os: 'OS óra szinkronizálva', + status_error: 'Utolsó hiba', + os_yes: 'Igen', + os_no: 'Nem', + }, + id: { + synced_detail: 'Jam disinkronkan untuk penagihan', + ntp_servers: 'Server NTP', + ntp_servers_hint: 'Nama host atau IP dipisah koma. Berlaku untuk server Go (betterdesk-server).', + max_skew_ms: 'Selisih jam maksimum (ms)', + require_sync: 'Wajib jam tersinkron untuk sesi berbayar', + trust_os: 'Percayai NTP OS jika kueri langsung gagal', + trust_os_hint: 'Gunakan status systemd-timesyncd/chrony saat UDP NTP diblokir (DMZ/firewall).', + save: 'Simpan pengaturan NTP', + saved: 'Pengaturan jam disimpan. Restart server Go dimulai.', + save_restart: 'Simpan NTP dan restart server Go? Sesi aktif mungkin terputus sebentar.', + status_offset: 'Offset', + status_server: 'Sumber NTP', + status_os: 'Jam OS tersinkron', + status_error: 'Kesalahan terakhir', + os_yes: 'Ya', + os_no: 'Tidak', + }, + it: { + synced_detail: 'Orologio sincronizzato per la fatturazione', + ntp_servers: 'Server NTP', + ntp_servers_hint: 'Hostname o IP separati da virgola. Si applica al server Go (betterdesk-server).', + max_skew_ms: 'Scostamento orario massimo (ms)', + require_sync: 'Richiedi orologio sincronizzato per sessioni fatturabili', + trust_os: 'Affidati all\'NTP del SO se le query dirette falliscono', + trust_os_hint: 'Usa lo stato systemd-timesyncd/chrony quando l\'UDP NTP è bloccato (DMZ/firewall).', + save: 'Salva impostazioni NTP', + saved: 'Impostazioni orologio salvate. Riavvio server Go avviato.', + save_restart: 'Salvare NTP e riavviare il server Go? Le sessioni attive possono disconnettersi brevemente.', + status_offset: 'Scostamento', + status_server: 'Fonte NTP', + status_os: 'Orologio OS sincronizzato', + status_error: 'Ultimo errore', + os_yes: 'Sì', + os_no: 'No', + }, + ja: { + synced_detail: '課金用に時計が同期されています', + ntp_servers: 'NTPサーバー', + ntp_servers_hint: 'カンマ区切りのホスト名またはIP。Goサーバー(betterdesk-server)に適用されます。', + max_skew_ms: '最大時刻ずれ (ms)', + require_sync: '課金セッションに同期時計を必須にする', + trust_os: '直接クエリ失敗時はOS NTPを信頼', + trust_os_hint: 'UDP NTPがブロックされている場合は systemd-timesyncd/chrony の状態を使用。', + save: 'NTP設定を保存', + saved: '時計設定を保存しました。Goサーバーの再起動を開始しました。', + save_restart: 'NTPを保存してGoサーバーを再起動しますか?アクティブなセッションが一時的に切断される場合があります。', + status_offset: 'オフセット', + status_server: 'NTPソース', + status_os: 'OS時計同期', + status_error: '最後のエラー', + os_yes: 'はい', + os_no: 'いいえ', + }, + ko: { + synced_detail: '과금을 위해 시계가 동기화됨', + ntp_servers: 'NTP 서버', + ntp_servers_hint: '쉼표로 구분된 호스트명 또는 IP. Go 서버(betterdesk-server)에 적용됩니다.', + max_skew_ms: '최대 시계 오차 (ms)', + require_sync: '과금 세션에 동기화된 시계 필요', + trust_os: '직접 쿼리 실패 시 OS NTP 신뢰', + trust_os_hint: 'UDP NTP가 차단된 경우 systemd-timesyncd/chrony 상태 사용.', + save: 'NTP 설정 저장', + saved: '시계 설정이 저장되었습니다. Go 서버 재시작이 시작되었습니다.', + save_restart: 'NTP를 저장하고 Go 서버를 재시작하시겠습니까? 활성 세션이 잠시 끊길 수 있습니다.', + status_offset: '오프셋', + status_server: 'NTP 소스', + status_os: 'OS 시계 동기화', + status_error: '마지막 오류', + os_yes: '예', + os_no: '아니오', + }, + nb: { + synced_detail: 'Klokke synkronisert for fakturering', + ntp_servers: 'NTP-servere', + ntp_servers_hint: 'Kommaseparerte vertsnavn eller IP. Gjelder Go-serveren (betterdesk-server).', + max_skew_ms: 'Maks klokkeavvik (ms)', + require_sync: 'Krev synkronisert klokke for fakturerbare økter', + trust_os: 'Stol på OS NTP når direkte forespørsler feiler', + trust_os_hint: 'Bruk systemd-timesyncd/chrony-status når UDP NTP er blokkert.', + save: 'Lagre NTP-innstillinger', + saved: 'Klokkeinnstillinger lagret. Omstart av Go-server startet.', + save_restart: 'Lagre NTP og start Go-server på nytt? Aktive økter kan kort kobles fra.', + status_offset: 'Avvik', + status_server: 'NTP-kilde', + status_os: 'OS-klokke synkronisert', + status_error: 'Siste feil', + os_yes: 'Ja', + os_no: 'Nei', + }, + nl: { + synced_detail: 'Klok gesynchroniseerd voor facturering', + ntp_servers: 'NTP-servers', + ntp_servers_hint: 'Kommagescheiden hostnamen of IP\'s. Geldt voor de Go-server (betterdesk-server).', + max_skew_ms: 'Maximale klokafwijking (ms)', + require_sync: 'Gesynchroniseerde klok vereisen voor factureerbare sessies', + trust_os: 'Vertrouw OS NTP wanneer directe queries falen', + trust_os_hint: 'Gebruik systemd-timesyncd/chrony-status wanneer UDP NTP geblokkeerd is.', + save: 'NTP-instellingen opslaan', + saved: 'Klokinstellingen opgeslagen. Herstart Go-server gestart.', + save_restart: 'NTP opslaan en Go-server herstarten? Actieve sessies kunnen kort verbreken.', + status_offset: 'Offset', + status_server: 'NTP-bron', + status_os: 'OS-klok gesynchroniseerd', + status_error: 'Laatste fout', + os_yes: 'Ja', + os_no: 'Nee', + }, + pl: { + synced_detail: 'Zegar zsynchronizowany do rozliczeń', + ntp_servers: 'Serwery NTP', + ntp_servers_hint: 'Nazwy hostów lub IP oddzielone przecinkami. Dotyczy serwera Go (betterdesk-server).', + max_skew_ms: 'Maksymalne odchylenie zegara (ms)', + require_sync: 'Wymagaj zsynchronizowanego zegara dla sesji płatnych', + trust_os: 'Ufaj NTP systemu, gdy bezpośrednie zapytania zawiodą', + trust_os_hint: 'Użyj statusu systemd-timesyncd/chrony, gdy UDP NTP jest blokowane (DMZ/firewall).', + save: 'Zapisz ustawienia NTP', + saved: 'Ustawienia zegara zapisane. Uruchomiono restart serwera Go.', + save_restart: 'Zapisać NTP i zrestartować serwer Go? Aktywne sesje mogą się chwilowo rozłączyć.', + status_offset: 'Odchylenie', + status_server: 'Źródło NTP', + status_os: 'Zegar OS zsynchronizowany', + status_error: 'Ostatni błąd', + os_yes: 'Tak', + os_no: 'Nie', + }, + pt: { + synced_detail: 'Relógio sincronizado para faturação', + ntp_servers: 'Servidores NTP', + ntp_servers_hint: 'Nomes de host ou IP separados por vírgula. Aplica-se ao servidor Go (betterdesk-server).', + max_skew_ms: 'Desvio máximo do relógio (ms)', + require_sync: 'Exigir relógio sincronizado para sessões faturáveis', + trust_os: 'Confiar no NTP do SO quando consultas diretas falharem', + trust_os_hint: 'Usar estado systemd-timesyncd/chrony quando UDP NTP estiver bloqueado.', + save: 'Guardar definições NTP', + saved: 'Definições de relógio guardadas. Reinício do servidor Go iniciado.', + save_restart: 'Guardar NTP e reiniciar o servidor Go? Sessões ativas podem desligar-se brevemente.', + status_offset: 'Desvio', + status_server: 'Fonte NTP', + status_os: 'Relógio do SO sincronizado', + status_error: 'Último erro', + os_yes: 'Sim', + os_no: 'Não', + }, + ro: { + synced_detail: 'Ceas sincronizat pentru facturare', + ntp_servers: 'Servere NTP', + ntp_servers_hint: 'Nume gazdă sau IP separate prin virgulă. Se aplică serverului Go (betterdesk-server).', + max_skew_ms: 'Abatere maximă ceas (ms)', + require_sync: 'Cere ceas sincronizat pentru sesiuni facturabile', + trust_os: 'Ai încredere în NTP OS când interogările directe eșuează', + trust_os_hint: 'Folosește starea systemd-timesyncd/chrony când UDP NTP este blocat.', + save: 'Salvează setările NTP', + saved: 'Setările ceasului salvate. Repornire server Go inițiată.', + save_restart: 'Salvezi NTP și repornești serverul Go? Sesiunile active se pot deconecta scurt.', + status_offset: 'Abatere', + status_server: 'Sursă NTP', + status_os: 'Ceas OS sincronizat', + status_error: 'Ultima eroare', + os_yes: 'Da', + os_no: 'Nu', + }, + sv: { + synced_detail: 'Klocka synkroniserad för fakturering', + ntp_servers: 'NTP-servrar', + ntp_servers_hint: 'Kommaseparerade värdnamn eller IP. Gäller Go-servern (betterdesk-server).', + max_skew_ms: 'Max klockavvikelse (ms)', + require_sync: 'Kräv synkad klocka för fakturerbara sessioner', + trust_os: 'Lita på OS NTP när direkta frågor misslyckas', + trust_os_hint: 'Använd systemd-timesyncd/chrony-status när UDP NTP blockeras.', + save: 'Spara NTP-inställningar', + saved: 'Klockinställningar sparade. Omstart av Go-server startad.', + save_restart: 'Spara NTP och starta om Go-servern? Aktiva sessioner kan kopplas från kort.', + status_offset: 'Avvikelse', + status_server: 'NTP-källa', + status_os: 'OS-klocka synkad', + status_error: 'Senaste fel', + os_yes: 'Ja', + os_no: 'Nej', + }, + th: { + synced_detail: 'นาฬิกาซิงค์สำหรับการเรียกเก็บเงิน', + ntp_servers: 'เซิร์ฟเวอร์ NTP', + ntp_servers_hint: 'ชื่อโฮสต์หรือ IP คั่นด้วยจุลภาค ใช้กับเซิร์ฟเวอร์ Go (betterdesk-server)', + max_skew_ms: 'ความคลาดเคลื่อนสูงสุดของนาฬิกา (ms)', + require_sync: 'ต้องมีนาฬิกาซิงค์สำหรับเซสชันที่เรียกเก็บเงิน', + trust_os: 'เชื่อถือ NTP ของ OS เมื่อการสอบถามโดยตรงล้มเหลว', + trust_os_hint: 'ใช้สถานะ systemd-timesyncd/chrony เมื่อ UDP NTP ถูกบล็อก', + save: 'บันทึกการตั้งค่า NTP', + saved: 'บันทึกการตั้งค่านาฬิกาแล้ว เริ่มรีสตาร์ทเซิร์ฟเวอร์ Go', + save_restart: 'บันทึก NTP และรีสตาร์ทเซิร์ฟเวอร์ Go? เซสชันที่ใช้งานอาจหลุดชั่วคราว', + status_offset: 'ออฟเซ็ต', + status_server: 'แหล่ง NTP', + status_os: 'นาฬิกา OS ซิงค์', + status_error: 'ข้อผิดพลาดล่าสุด', + os_yes: 'ใช่', + os_no: 'ไม่', + }, + tr: { + synced_detail: 'Faturalandırma için saat senkronize', + ntp_servers: 'NTP sunucuları', + ntp_servers_hint: 'Virgülle ayrılmış ana bilgisayar adları veya IP. Go sunucusuna (betterdesk-server) uygulanır.', + max_skew_ms: 'Maksimum saat sapması (ms)', + require_sync: 'Faturalandırılabilir oturumlar için senkron saat gerekli', + trust_os: 'Doğrudan sorgular başarısız olursa OS NTP\'ye güven', + trust_os_hint: 'UDP NTP engellendiğinde systemd-timesyncd/chrony durumunu kullanın.', + save: 'NTP ayarlarını kaydet', + saved: 'Saat ayarları kaydedildi. Go sunucusu yeniden başlatılıyor.', + save_restart: 'NTP kaydedilsin ve Go sunucusu yeniden başlatılsın mı? Aktif oturumlar kısa süre kesilebilir.', + status_offset: 'Sapma', + status_server: 'NTP kaynağı', + status_os: 'OS saati senkron', + status_error: 'Son hata', + os_yes: 'Evet', + os_no: 'Hayır', + }, + uk: { + synced_detail: 'Годинник синхронізовано для білінгу', + ntp_servers: 'NTP-сервери', + ntp_servers_hint: 'Імена хостів або IP через кому. Застосовується до Go-сервера (betterdesk-server).', + max_skew_ms: 'Максимальне відхилення годинника (мс)', + require_sync: 'Вимагати синхронізований годинник для платних сесій', + trust_os: 'Довіряти OS NTP, якщо прямі запити не вдаються', + trust_os_hint: 'Використовуйте статус systemd-timesyncd/chrony, коли UDP NTP заблоковано.', + save: 'Зберегти налаштування NTP', + saved: 'Налаштування годинника збережено. Перезапуск Go-сервера розпочато.', + save_restart: 'Зберегти NTP і перезапустити Go-сервер? Активні сесії можуть коротко відключитися.', + status_offset: 'Відхилення', + status_server: 'Джерело NTP', + status_os: 'Годинник OS синхронізовано', + status_error: 'Остання помилка', + os_yes: 'Так', + os_no: 'Ні', + }, + vi: { + synced_detail: 'Đồng hồ đã đồng bộ cho tính phí', + ntp_servers: 'Máy chủ NTP', + ntp_servers_hint: 'Tên máy chủ hoặc IP cách nhau bằng dấu phẩy. Áp dụng cho máy chủ Go (betterdesk-server).', + max_skew_ms: 'Độ lệch đồng hồ tối đa (ms)', + require_sync: 'Yêu cầu đồng hồ đồng bộ cho phiên tính phí', + trust_os: 'Tin cậy NTP OS khi truy vấn trực tiếp thất bại', + trust_os_hint: 'Dùng trạng thái systemd-timesyncd/chrony khi UDP NTP bị chặn.', + save: 'Lưu cài đặt NTP', + saved: 'Đã lưu cài đặt đồng hồ. Đang khởi động lại máy chủ Go.', + save_restart: 'Lưu NTP và khởi động lại máy chủ Go? Phiên đang hoạt động có thể ngắt tạm thời.', + status_offset: 'Độ lệch', + status_server: 'Nguồn NTP', + status_os: 'Đồng hồ OS đã đồng bộ', + status_error: 'Lỗi cuối', + os_yes: 'Có', + os_no: 'Không', + }, + zh: { + synced_detail: '时钟已同步,可用于计费', + ntp_servers: 'NTP 服务器', + ntp_servers_hint: '逗号分隔的主机名或 IP。应用于 Go 服务器(betterdesk-server)。', + max_skew_ms: '最大时钟偏差 (ms)', + require_sync: '可计费会话要求时钟同步', + trust_os: '直接查询失败时信任 OS NTP', + trust_os_hint: 'UDP NTP 被阻止时使用 systemd-timesyncd/chrony 状态。', + save: '保存 NTP 设置', + saved: '时钟设置已保存。已启动 Go 服务器重启。', + save_restart: '保存 NTP 并重启 Go 服务器?活动会话可能短暂断开。', + status_offset: '偏移', + status_server: 'NTP 来源', + status_os: 'OS 时钟已同步', + status_error: '最后错误', + os_yes: '是', + os_no: '否', + }, + 'zh-TW': { + synced_detail: '時鐘已同步,可用於計費', + ntp_servers: 'NTP 伺服器', + ntp_servers_hint: '逗號分隔的主機名稱或 IP。套用至 Go 伺服器(betterdesk-server)。', + max_skew_ms: '最大時鐘偏差 (ms)', + require_sync: '可計費工作階段要求時鐘同步', + trust_os: '直接查詢失敗時信任 OS NTP', + trust_os_hint: 'UDP NTP 被阻擋時使用 systemd-timesyncd/chrony 狀態。', + save: '儲存 NTP 設定', + saved: '時鐘設定已儲存。已啟動 Go 伺服器重新啟動。', + save_restart: '儲存 NTP 並重新啟動 Go 伺服器?使用中工作階段可能短暫中斷。', + status_offset: '偏移', + status_server: 'NTP 來源', + status_os: 'OS 時鐘已同步', + status_error: '最後錯誤', + os_yes: '是', + os_no: '否', + }, +}; + +let updated = 0; +for (const file of locales) { + const locale = file.replace(/\.json$/, ''); + const patch = patches[locale]; + if (!patch) continue; + const filePath = path.join(langDir, file); + const json = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (!json.commercialization?.clock) continue; + Object.assign(json.commercialization.clock, patch); + fs.writeFileSync(filePath, `${JSON.stringify(json, null, 2)}\n`, 'utf8'); + updated += 1; +} + +console.log(`Patched commercialization.clock in ${updated} locale files`); diff --git a/web-nodejs/services/billingClockConfigService.js b/web-nodejs/services/billingClockConfigService.js new file mode 100644 index 00000000..22f11fd6 --- /dev/null +++ b/web-nodejs/services/billingClockConfigService.js @@ -0,0 +1,147 @@ +'use strict'; + +const fs = require('fs'); +const net = require('net'); +const path = require('path'); +const { upsertEnvKey } = require('../lib/envMerge'); +const updateService = require('./updateService'); +const serverConnectionConfigService = require('./serverConnectionConfigService'); + +const CONSOLE_ROOT = path.join(__dirname, '..'); +const ENV_PATH = path.join(CONSOLE_ROOT, '.env'); + +const ENV_KEYS = { + ntp_servers: 'NTP_SERVERS', + max_skew_ms: 'BILLING_MAX_CLOCK_SKEW_MS', + require_synced_clock: 'BILLING_REQUIRE_SYNCED_CLOCK', + trust_os_ntp: 'BILLING_TRUST_OS_NTP', +}; + +const DEFAULTS = { + ntp_servers: 'pool.ntp.org,time.google.com,time.cloudflare.com', + max_skew_ms: 2000, + require_synced_clock: true, + trust_os_ntp: process.platform === 'linux', +}; + +function parseEnvFile(content) { + const out = {}; + if (!content) return out; + for (const line of String(content).split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); + } + return out; +} + +function envToBool(value, defaultValue) { + if (value === undefined || value === null || value === '') return defaultValue; + const v = String(value).trim().toUpperCase(); + if (['Y', 'YES', '1', 'TRUE', 'ON'].includes(v)) return true; + if (['N', 'NO', '0', 'FALSE', 'OFF'].includes(v)) return false; + return defaultValue; +} + +function boolToEnv(value) { + return value ? '1' : '0'; +} + +function isValidNTPServerEntry(entry) { + const s = String(entry || '').trim(); + if (!s || s.length > 253) return false; + if (net.isIP(s)) return true; + return /^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(s); +} + +function normalizeSettings(raw = {}) { + const servers = String(raw.ntp_servers ?? DEFAULTS.ntp_servers) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const maxSkew = Number.parseInt(raw.max_skew_ms, 10); + return { + ntp_servers: servers.join(','), + max_skew_ms: Number.isFinite(maxSkew) && maxSkew > 0 ? maxSkew : DEFAULTS.max_skew_ms, + require_synced_clock: raw.require_synced_clock !== undefined + ? !!raw.require_synced_clock + : DEFAULTS.require_synced_clock, + trust_os_ntp: raw.trust_os_ntp !== undefined + ? !!raw.trust_os_ntp + : DEFAULTS.trust_os_ntp, + }; +} + +function validateSettings(settings) { + const normalized = normalizeSettings(settings); + const servers = normalized.ntp_servers.split(',').map((s) => s.trim()).filter(Boolean); + if (!servers.length) { + throw new Error('invalid_ntp_servers'); + } + for (const server of servers) { + if (!isValidNTPServerEntry(server)) { + throw new Error('invalid_ntp_servers'); + } + } + if (normalized.max_skew_ms <= 0 || normalized.max_skew_ms > 600000) { + throw new Error('invalid_max_skew'); + } + return normalized; +} + +function settingsFromEnvContent(content) { + const env = parseEnvFile(content); + return normalizeSettings({ + ntp_servers: env[ENV_KEYS.ntp_servers] || DEFAULTS.ntp_servers, + max_skew_ms: env[ENV_KEYS.max_skew_ms] || DEFAULTS.max_skew_ms, + require_synced_clock: envToBool(env[ENV_KEYS.require_synced_clock], DEFAULTS.require_synced_clock), + trust_os_ntp: envToBool(env[ENV_KEYS.trust_os_ntp], DEFAULTS.trust_os_ntp), + }); +} + +function getClockSettings() { + const content = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : ''; + return settingsFromEnvContent(content); +} + +function writeClockSettingsToEnv(settings) { + const normalized = validateSettings(settings); + let content = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : ''; + content = upsertEnvKey(content, ENV_KEYS.ntp_servers, normalized.ntp_servers); + content = upsertEnvKey(content, ENV_KEYS.max_skew_ms, String(normalized.max_skew_ms)); + content = upsertEnvKey(content, ENV_KEYS.require_synced_clock, boolToEnv(normalized.require_synced_clock)); + content = upsertEnvKey(content, ENV_KEYS.trust_os_ntp, boolToEnv(normalized.trust_os_ntp)); + fs.writeFileSync(ENV_PATH, content, { encoding: 'utf8', mode: 0o600 }); + return normalized; +} + +async function saveClockSettings(settings, opts = {}) { + const normalized = writeClockSettingsToEnv(settings); + const serviceConfig = updateService.sanitizeGoServerServiceConfig(); + const result = { + settings: normalized, + serviceConfig, + restart: null, + }; + if (opts.restart !== false) { + result.restart = serverConnectionConfigService.restartServer(); + } + return result; +} + +module.exports = { + DEFAULTS, + ENV_PATH, + ENV_KEYS, + parseEnvFile, + envToBool, + boolToEnv, + isValidNTPServerEntry, + normalizeSettings, + validateSettings, + settingsFromEnvContent, + getClockSettings, + saveClockSettings, +}; diff --git a/web-nodejs/services/updateService.js b/web-nodejs/services/updateService.js index cd34c868..ec451a04 100644 --- a/web-nodejs/services/updateService.js +++ b/web-nodejs/services/updateService.js @@ -742,6 +742,75 @@ function runPrivileged(command, options = {}) { /** * Ensure MESH_ENABLED=Y is present in Go server service environment (one-time migration). */ +const BILLING_ENV_KEYS = [ + 'NTP_SERVERS', + 'BILLING_MAX_CLOCK_SKEW_MS', + 'BILLING_REQUIRE_SYNCED_CLOCK', + 'BILLING_TRUST_OS_NTP', +]; + +function parseEnvFileKeys(content, keys) { + const out = {}; + if (!content || typeof content !== 'string') return out; + const wanted = new Set(keys); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + if (!wanted.has(key)) continue; + out[key] = trimmed.slice(eq + 1).trim(); + } + return out; +} + +function mergeBillingEnvIntoWindowsServiceExtra(existingExtra, billingVars) { + const lines = (existingExtra || '') + .replace(/\r\n/g, '\n') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + const map = new Map(); + for (const line of lines) { + const eq = line.indexOf('='); + if (eq <= 0) continue; + map.set(line.slice(0, eq), line.slice(eq + 1)); + } + let changed = false; + for (const key of BILLING_ENV_KEYS) { + if (billingVars[key] === undefined) continue; + const nextVal = billingVars[key]; + if (map.get(key) !== nextVal) { + map.set(key, nextVal); + changed = true; + } + } + if (!changed) return { text: existingExtra, changed: false }; + const merged = [...map.entries()].map(([k, v]) => `${k}=${v}`).join('\n'); + return { text: merged, changed: true }; +} + +function syncBillingEnvToWindowsGoServer() { + const envPath = path.join(ROOT_DIR, '.env'); + if (!fs.existsSync(envPath)) return { changed: false }; + const billingVars = parseEnvFileKeys(fs.readFileSync(envPath, 'utf8'), BILLING_ENV_KEYS); + if (!Object.keys(billingVars).length) return { changed: false }; + + const serviceName = COMPONENTS.server.service; + const serverEnvRaw = execSync(`nssm get "${serviceName}" AppEnvironmentExtra 2>nul`, { + timeout: 5000, + stdio: 'pipe' + }).toString(); + const patch = mergeBillingEnvIntoWindowsServiceExtra(serverEnvRaw, billingVars); + if (!patch.changed) return { changed: false }; + execFileSync('nssm', ['set', serviceName, 'AppEnvironmentExtra', patch.text], { + timeout: 5000, + stdio: 'pipe' + }); + return { changed: true }; +} + function ensureMeshEnabledInServiceEnv(envText) { if (!envText || typeof envText !== 'string') return { text: envText, changed: false }; if (/^MESH_ENABLED=/m.test(envText)) return { text: envText, changed: false }; @@ -750,6 +819,28 @@ function ensureMeshEnabledInServiceEnv(envText) { return { text: trimmed ? `${trimmed}\n${line}` : line, changed: true }; } +/** + * Ensure Go server systemd unit loads console .env (NTP / billing keys for timesync). + */ +function ensureGoServerEnvironmentFile(unitText, envFilePath) { + if (!unitText || typeof unitText !== 'string') return { text: unitText, changed: false }; + if (/^EnvironmentFile=/m.test(unitText)) return { text: unitText, changed: false }; + const envLine = `EnvironmentFile=-${envFilePath}`; + if (/^Environment=AUTH_DB_PATH=/m.test(unitText)) { + return { + text: unitText.replace(/^(Environment=AUTH_DB_PATH=.*)$/m, `${envLine}\n$1`), + changed: true + }; + } + if (/^\[Service\]/m.test(unitText)) { + return { + text: unitText.replace(/^\[Service\]/m, `[Service]\n${envLine}`), + changed: true + }; + } + return { text: `${envLine}\n${unitText}`, changed: true }; +} + function sanitizeGoServerServiceConfig() { const result = { changed: false, changes: [], error: null, needsRestart: false }; @@ -787,6 +878,15 @@ function sanitizeGoServerServiceConfig() { } } catch (_e) { /* server service may not exist */ } + try { + const billingPatch = syncBillingEnvToWindowsGoServer(); + if (billingPatch.changed) { + result.changed = true; + result.needsRestart = true; + result.changes.push('synced billing/NTP env to BetterDesk Go Server NSSM environment'); + } + } catch (_e) { /* server service may not exist */ } + try { const consoleService = COMPONENTS.console.service; const envRaw = execSync(`nssm get "${consoleService}" AppEnvironmentExtra 2>nul`, { @@ -835,6 +935,14 @@ function sanitizeGoServerServiceConfig() { result.changes.push('set MESH_ENABLED=Y in betterdesk-server systemd unit'); } + const consoleEnvPath = path.join(ROOT_DIR, '.env'); + const envFilePatch = ensureGoServerEnvironmentFile(clean, consoleEnvPath); + if (envFilePatch.changed) { + clean = envFilePatch.text; + result.needsRestart = true; + result.changes.push('set EnvironmentFile for console .env on betterdesk-server systemd unit'); + } + if (clean !== original) { writeTextFilePrivileged(fragmentPath, clean); runPrivileged('systemctl daemon-reload', { timeout: 10000, stdio: 'pipe' }); @@ -3087,6 +3195,9 @@ module.exports = { createPreUpdateBackup, applyUpdate, runUpdatePreflight, + sanitizeGoServerServiceConfig, + syncBillingEnvToWindowsGoServer, + BILLING_ENV_KEYS, restartService, daemonReload, listBackups, diff --git a/web-nodejs/tests/billingClockConfigService.test.js b/web-nodejs/tests/billingClockConfigService.test.js new file mode 100644 index 00000000..a66b94fe --- /dev/null +++ b/web-nodejs/tests/billingClockConfigService.test.js @@ -0,0 +1,58 @@ +'use strict'; + +const { + validateSettings, + normalizeSettings, + settingsFromEnvContent, + isValidNTPServerEntry, + envToBool, + boolToEnv, +} = require('../services/billingClockConfigService'); + +describe('billingClockConfigService', () => { + test('normalizeSettings trims NTP server list', () => { + const out = normalizeSettings({ + ntp_servers: ' 10.0.0.1 , ntp.example.com , ', + max_skew_ms: '3000', + }); + expect(out.ntp_servers).toBe('10.0.0.1,ntp.example.com'); + expect(out.max_skew_ms).toBe(3000); + }); + + test('validateSettings rejects invalid hostnames', () => { + expect(() => validateSettings({ ntp_servers: 'bad host name' })) + .toThrow('invalid_ntp_servers'); + }); + + test('validateSettings accepts IPv4 NTP servers', () => { + const out = validateSettings({ ntp_servers: '192.168.1.1' }); + expect(out.ntp_servers).toBe('192.168.1.1'); + }); + + test('settingsFromEnvContent reads billing keys', () => { + const content = [ + 'NTP_SERVERS=10.0.0.1', + 'BILLING_MAX_CLOCK_SKEW_MS=1500', + 'BILLING_REQUIRE_SYNCED_CLOCK=0', + 'BILLING_TRUST_OS_NTP=1', + ].join('\n'); + const settings = settingsFromEnvContent(content); + expect(settings.ntp_servers).toBe('10.0.0.1'); + expect(settings.max_skew_ms).toBe(1500); + expect(settings.require_synced_clock).toBe(false); + expect(settings.trust_os_ntp).toBe(true); + }); + + test('isValidNTPServerEntry validates IP and hostname', () => { + expect(isValidNTPServerEntry('10.0.0.1')).toBe(true); + expect(isValidNTPServerEntry('time.google.com')).toBe(true); + expect(isValidNTPServerEntry('')).toBe(false); + }); + + test('envToBool and boolToEnv round-trip', () => { + expect(envToBool('Y', false)).toBe(true); + expect(envToBool('0', true)).toBe(false); + expect(boolToEnv(true)).toBe('1'); + expect(boolToEnv(false)).toBe('0'); + }); +}); diff --git a/web-nodejs/views/commercialization.ejs b/web-nodejs/views/commercialization.ejs index 5cb4f586..96837000 100644 --- a/web-nodejs/views/commercialization.ejs +++ b/web-nodejs/views/commercialization.ejs @@ -109,8 +109,31 @@

${_('commercialization.clock.title')}

-

+

+
+
${_('commercialization.clock.status_offset')}
+
${_('commercialization.clock.status_server')}
+
${_('commercialization.clock.status_os')}
+
${_('commercialization.clock.status_error')}
+
+
+ +

${_('commercialization.clock.ntp_servers_hint')}

+ +
+ +
+
+ +
+

${_('commercialization.clock.trust_os_hint')}

+ +

${_('commercialization.notifications.title')}