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.
This commit is contained in:
UNITRONIX
2026-06-27 20:33:56 +02:00
parent 84a07c04d6
commit bb2ff08f65
45 changed files with 1564 additions and 47 deletions
+3
View File
@@ -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)_
+6
View File
@@ -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.
+11
View File
@@ -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
+13
View File
@@ -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)
+60 -16
View File
@@ -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
@@ -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")
}
}
+29
View File
@@ -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
+2
View File
@@ -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
+4
View File
@@ -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):
+4
View File
@@ -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
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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
+17 -1
View File
@@ -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": "جلسات قابلة للفوترة نشطة"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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": "सक्रिय बिल योग्य सत्र"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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": "アクティブな課金セッション"
+17 -1
View File
@@ -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": "활성 과금 세션"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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": "เซสชันที่เรียกเก็บเงินที่ใช้งานอยู่"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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": "Активні платні сесії"
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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": "活動可計費會話"
+17 -1
View File
@@ -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": "活动可计费会话"
+57 -2
View File
@@ -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);
@@ -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');
});
+476
View File
@@ -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`);
@@ -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,
};
+111
View File
@@ -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,
@@ -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');
});
});
+24 -1
View File
@@ -109,8 +109,31 @@
<section id="tab-settings" class="tab-panel ${activeTab === 'settings' ? '' : 'hidden'}">
<div class="card">
<h3>${_('commercialization.clock.title')}</h3>
<p id="settings-clock-detail"></p>
<p id="settings-clock-detail" class="text-muted"></p>
<dl class="clock-status-grid">
<div><dt>${_('commercialization.clock.status_offset')}</dt><dd id="clock-offset"></dd></div>
<div><dt>${_('commercialization.clock.status_server')}</dt><dd id="clock-ntp-server"></dd></div>
<div><dt>${_('commercialization.clock.status_os')}</dt><dd id="clock-os-sync"></dd></div>
<div><dt>${_('commercialization.clock.status_error')}</dt><dd id="clock-last-error"></dd></div>
</dl>
<button type="button" class="btn btn-secondary" id="btn-timesync-check">${_('commercialization.clock.check_now')}</button>
<form id="clock-settings-form" class="settings-form" style="margin-top: 1rem;">
<label class="form-label">${_('commercialization.clock.ntp_servers')}
<textarea id="clock-ntp-servers" class="form-control" rows="2" placeholder="pool.ntp.org,time.google.com"></textarea>
</label>
<p class="text-muted form-hint">${_('commercialization.clock.ntp_servers_hint')}</p>
<label class="form-label">${_('commercialization.clock.max_skew_ms')}
<input type="number" id="clock-max-skew" class="form-control" min="100" max="600000" step="100" value="2000">
</label>
<div class="form-group">
<label><input type="checkbox" id="clock-require-sync"> ${_('commercialization.clock.require_sync')}</label>
</div>
<div class="form-group">
<label><input type="checkbox" id="clock-trust-os"> ${_('commercialization.clock.trust_os')}</label>
</div>
<p class="text-muted form-hint">${_('commercialization.clock.trust_os_hint')}</p>
<button type="button" class="btn btn-primary" id="btn-save-clock-settings">${_('commercialization.clock.save')}</button>
</form>
</div>
<div class="card" style="margin-top: 1rem;">
<h3>${_('commercialization.notifications.title')}</h3>