feat: add TZ timezone support for all displayed timestamps
This commit is contained in:
@@ -19,6 +19,11 @@ KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
|
||||
# Log level: ERROR, WARN, INFO (default), DEBUG, TRACE
|
||||
KEYWARDEN_LOG_LEVEL=INFO
|
||||
|
||||
# --- Timezone ---
|
||||
# IANA timezone name (e.g. Europe/Berlin, America/New_York).
|
||||
# Affects all displayed timestamps in the UI.
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# --- Paths (optional, Docker defaults are usually fine) ---
|
||||
# IMPORTANT: These paths refer to locations INSIDE the Docker container.
|
||||
# The Dockerfile already sets correct defaults (/data/...). Only override
|
||||
|
||||
@@ -40,6 +40,7 @@ ENV KEYWARDEN_DATA_DIR=/data
|
||||
ENV KEYWARDEN_KEYS_DIR=/data/keys
|
||||
ENV KEYWARDEN_MASTER_DIR=/data/master
|
||||
ENV KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
|
||||
ENV TZ=UTC
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/audit"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/auth"
|
||||
@@ -46,11 +47,15 @@ func main() {
|
||||
// Load config first (needed for log level)
|
||||
cfg := config.Load()
|
||||
|
||||
// Set application-wide timezone from TZ environment variable
|
||||
time.Local = cfg.Timezone
|
||||
|
||||
// Initialize structured logging
|
||||
logging.Init(cfg.LogLevel)
|
||||
|
||||
logging.Info("🔑 Keywarden %s - Centralized SSH Key Management and Deployment", version.Version)
|
||||
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
|
||||
logging.Info("Timezone: %s", cfg.Timezone)
|
||||
|
||||
// Validate data paths – relative paths inside a container bypass the
|
||||
// persistent volume mount and lead to silent data loss on restart.
|
||||
|
||||
@@ -98,6 +98,9 @@ KEYWARDEN_ENCRYPTION_KEY=generate-another-random-string-32-chars
|
||||
KEYWARDEN_PORT=8080
|
||||
KEYWARDEN_LOG_LEVEL=INFO
|
||||
|
||||
# Timezone (IANA, e.g. Europe/Berlin, America/New_York)
|
||||
TZ=UTC
|
||||
|
||||
# Initial owner (only used on first startup)
|
||||
KEYWARDEN_OWNER_USER=admin
|
||||
KEYWARDEN_OWNER_EMAIL=admin@example.com
|
||||
|
||||
@@ -12,6 +12,7 @@ Complete reference of all configuration options for Keywarden. All settings are
|
||||
| `KEYWARDEN_KEYS_DIR` | `./data/keys` | Directory for key storage (reserved) |
|
||||
| `KEYWARDEN_MASTER_DIR` | `./data/master` | Directory for master key storage (reserved) |
|
||||
| `KEYWARDEN_LOG_LEVEL` | `INFO` | Log level: `ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE` |
|
||||
| `TZ` | `UTC` | Timezone for all displayed timestamps (e.g., `Europe/Berlin`, `America/New_York`). Uses standard IANA timezone names. |
|
||||
|
||||
## Security
|
||||
|
||||
@@ -60,6 +61,7 @@ When running in the Docker container, these defaults are set in the Dockerfile:
|
||||
| `KEYWARDEN_DATA_DIR` | `/data` |
|
||||
| `KEYWARDEN_KEYS_DIR` | `/data/keys` |
|
||||
| `KEYWARDEN_MASTER_DIR` | `/data/master` |
|
||||
| `TZ` | `UTC` |
|
||||
|
||||
## Example .env File
|
||||
|
||||
@@ -76,6 +78,9 @@ KEYWARDEN_ENCRYPTION_KEY=mX9nP2qR4sT6uV8wY0zA1bC3dE5fG7hI
|
||||
KEYWARDEN_PORT=8080
|
||||
KEYWARDEN_LOG_LEVEL=INFO
|
||||
|
||||
# Timezone (IANA timezone name, e.g. Europe/Berlin)
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# Initial owner (only used on first startup)
|
||||
KEYWARDEN_OWNER_USER=admin
|
||||
KEYWARDEN_OWNER_EMAIL=admin@example.com
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Configure timezone if TZ is set (requires tzdata package)
|
||||
if [ -n "$TZ" ] && [ -f "/usr/share/zoneinfo/$TZ" ]; then
|
||||
ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime
|
||||
echo "$TZ" > /etc/timezone
|
||||
fi
|
||||
|
||||
# Create data directories (bind-mount from host may be owned by root)
|
||||
mkdir -p /data/keys /data/master /data/avatars
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all application configuration
|
||||
@@ -30,6 +31,9 @@ type Config struct {
|
||||
SMTPTLS bool
|
||||
SMTPEnabled bool
|
||||
|
||||
// Timezone
|
||||
Timezone *time.Location // parsed from TZ env var; defaults to UTC
|
||||
|
||||
// Security / Hardening
|
||||
BaseURL string // e.g. "https://keywarden.example.com" (used for emails, cookie config)
|
||||
TrustedProxies string // comma-separated CIDRs, e.g. "10.0.0.0/8,172.16.0.0/12"
|
||||
@@ -55,6 +59,13 @@ func Load() *Config {
|
||||
rateLimitLogin := getEnvInt("KEYWARDEN_RATE_LIMIT_LOGIN", 10)
|
||||
maxRequestSize := getEnvInt64("KEYWARDEN_MAX_REQUEST_SIZE", 10*1024*1024) // 10 MB
|
||||
|
||||
// Load timezone from TZ environment variable (standard for Docker)
|
||||
tzName := getEnv("TZ", "UTC")
|
||||
tz, err := time.LoadLocation(tzName)
|
||||
if err != nil {
|
||||
tz = time.UTC
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: getEnv("KEYWARDEN_PORT", "8080"),
|
||||
DBPath: getEnv("KEYWARDEN_DB_PATH", "./data/keywarden.db"),
|
||||
@@ -73,6 +84,8 @@ func Load() *Config {
|
||||
SMTPTLS: getEnv("KEYWARDEN_SMTP_TLS", "true") == "true",
|
||||
SMTPEnabled: smtpHost != "",
|
||||
|
||||
Timezone: tz,
|
||||
|
||||
BaseURL: baseURL,
|
||||
TrustedProxies: getEnv("KEYWARDEN_TRUSTED_PROXIES", ""),
|
||||
SecureCookies: secureCookies,
|
||||
|
||||
@@ -194,6 +194,7 @@ type SystemInfo struct {
|
||||
Runtime string // e.g. "Docker" or "Native"
|
||||
Hostname string
|
||||
Uptime string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
// AdminUserInfo holds user info for the admin settings page
|
||||
@@ -344,6 +345,42 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
|
||||
subtitle, _ := h.auth.GetSetting("login_subtitle")
|
||||
return subtitle
|
||||
},
|
||||
// formatTime converts a time.Time to the app timezone and formats as "2006-01-02 15:04"
|
||||
"formatTime": func(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case time.Time:
|
||||
return t.Local().Format("2006-01-02 15:04")
|
||||
case *time.Time:
|
||||
if t != nil {
|
||||
return t.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
},
|
||||
// formatDateTime converts a time.Time to the app timezone and formats as "2006-01-02 15:04:05"
|
||||
"formatDateTime": func(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case time.Time:
|
||||
return t.Local().Format("2006-01-02 15:04:05")
|
||||
case *time.Time:
|
||||
if t != nil {
|
||||
return t.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
},
|
||||
// formatDateTimeLocal converts a time.Time to the app timezone and formats for HTML datetime-local inputs
|
||||
"formatDateTimeLocal": func(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case time.Time:
|
||||
return t.Local().Format("2006-01-02T15:04")
|
||||
case *time.Time:
|
||||
if t != nil {
|
||||
return t.Local().Format("2006-01-02T15:04")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
},
|
||||
}
|
||||
|
||||
baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html")
|
||||
@@ -3109,6 +3146,7 @@ func (h *Handler) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
|
||||
Runtime: runtimeEnv,
|
||||
Hostname: hostname,
|
||||
Uptime: uptimeStr,
|
||||
Timezone: time.Local.String(),
|
||||
}
|
||||
|
||||
data := &PageData{
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
<span class="badge bg-yellow-lt"><i class="ti ti-clock"></i> Pending</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-secondary">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td class="text-secondary">{{formatTime .CreatedAt}}</td>
|
||||
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{{range .AuditEntries}}
|
||||
<tr>
|
||||
<td class="text-secondary">
|
||||
<i class="ti ti-clock"></i> {{.CreatedAt.Format "2006-01-02 15:04:05"}}
|
||||
<i class="ti ti-clock"></i> {{formatDateTime .CreatedAt}}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-cyan-lt"><i class="ti ti-user"></i> {{.Username}}</span>
|
||||
|
||||
@@ -109,12 +109,12 @@
|
||||
{{if eq .Status "done"}}
|
||||
<span class="text-secondary">—</span>
|
||||
{{else}}
|
||||
{{.NextRun.Format "2006-01-02 15:04"}} <small class="text-secondary">UTC</small>
|
||||
{{formatTime .NextRun}}
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .LastRun}}
|
||||
{{.LastRun.Format "2006-01-02 15:04"}} <small class="text-secondary">UTC</small>
|
||||
{{formatTime .LastRun}}
|
||||
{{else}}
|
||||
<span class="text-secondary">never</span>
|
||||
{{end}}
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
<div class="mb-3 schedule-option" id="sched-once" {{if ne $job.Schedule "once"}}style="display:none;"{{end}}>
|
||||
<label class="form-label required">Date & Time</label>
|
||||
<input type="datetime-local" name="scheduled_at" class="form-control" id="cron-scheduled-at"
|
||||
value="{{$job.ScheduledAt.Format "2006-01-02T15:04"}}">
|
||||
value="{{formatDateTimeLocal $job.ScheduledAt}}">
|
||||
<small class="form-hint">Select the exact date and time for this one-time job.</small>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
<tbody>
|
||||
{{range .RecentAudit}}
|
||||
<tr>
|
||||
<td class="text-nowrap">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td class="text-nowrap">{{formatTime .CreatedAt}}</td>
|
||||
<td>{{.Username}}</td>
|
||||
<td><span class="badge bg-secondary-lt">{{.Action}}</span></td>
|
||||
<td class="text-truncate" style="max-width: 300px;">{{.Details}}</td>
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
<span class="badge bg-danger-lt"><i class="ti ti-x"></i> Failed</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>{{index . "deployed_at"}}</td>
|
||||
<td>{{formatDateTime (index . "deployed_at")}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</td>
|
||||
<td>{{.Bits}}</td>
|
||||
<td><code class="small">{{.Fingerprint}}</code></td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>{{formatTime .CreatedAt}}</td>
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
<button type="button" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key" onclick="showPublicKey({{.ID}}, '{{.Name}}')">
|
||||
@@ -89,7 +89,7 @@
|
||||
</td>
|
||||
<td>{{.Bits}}</td>
|
||||
<td><code class="small">{{.Fingerprint}}</code></td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>{{formatTime .CreatedAt}}</td>
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
<button type="button" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key" onclick="showPublicKey({{.ID}}, '{{.Name}}')">
|
||||
|
||||
@@ -66,6 +66,10 @@
|
||||
<div class="datagrid-title">Uptime</div>
|
||||
<div class="datagrid-content">{{.Uptime}}</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Timezone</div>
|
||||
<div class="datagrid-content">{{.Timezone}}</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Encryption</div>
|
||||
<div class="datagrid-content"><span class="badge bg-green-lt">AES-256-GCM</span></div>
|
||||
|
||||
@@ -60,12 +60,12 @@
|
||||
</td>
|
||||
<td class="text-secondary">
|
||||
{{if .LastLoginAt}}
|
||||
{{.LastLoginAt.Format "2006-01-02 15:04"}}
|
||||
{{formatTime .LastLoginAt}}
|
||||
{{else}}
|
||||
<span class="text-muted">Never</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-secondary">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td class="text-secondary">{{formatTime .CreatedAt}}</td>
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
{{if .LockedUntil}}
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3"><i class="ti ti-lock icon alert-icon"></i></div>
|
||||
<div class="flex-fill">
|
||||
<strong>Account locked</strong> until {{.EditUser.LockedUntil.Format "2006-01-02 15:04"}} ({{.EditUser.FailedLoginAttempts}} failed attempts)
|
||||
<strong>Account locked</strong> until {{formatTime .EditUser.LockedUntil}} ({{.EditUser.FailedLoginAttempts}} failed attempts)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user