Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 73 additions & 17 deletions mtproxymax.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2238,7 +2238,7 @@ secret_list() {
# Format creation date (use printf builtin when available, fallback to date)
local created_fmt
created_fmt=$(printf '%(%Y-%m-%d)T' "$created" 2>/dev/null) || \
created_fmt=$(date -d "@${created}" '+%Y-%m-%d' 2>/dev/null || echo "unknown")
created_fmt=$(_local_from_epoch "$created" '+%Y-%m-%d') || created_fmt="unknown"

# Get per-user traffic from batch-loaded arrays
local u_in=${_batch_cum_in["$label"]:-0}
Expand Down Expand Up @@ -2836,9 +2836,7 @@ secret_bulk_extend() {

local new_epoch=$((base_epoch + days * 86400))
local new_date
new_date=$(date -u -d "@${new_epoch}" '+%Y-%m-%dT23:59:59Z' 2>/dev/null) || \
new_date=$(date -u -r "$new_epoch" '+%Y-%m-%dT23:59:59Z' 2>/dev/null) || \
new_date=$(python3 -c "import datetime;print(datetime.datetime.utcfromtimestamp(${new_epoch}).strftime('%Y-%m-%dT23:59:59Z'))" 2>/dev/null)
new_date=$(_utc_from_epoch "$new_epoch" '+%Y-%m-%dT23:59:59Z')
[ -z "$new_date" ] && continue

SECRETS_EXPIRES[$i]="$new_date"
Expand Down Expand Up @@ -3050,9 +3048,7 @@ secret_extend() {

local new_epoch=$((base_epoch + days * 86400))
local new_date
new_date=$(date -u -d "@${new_epoch}" '+%Y-%m-%dT23:59:59Z' 2>/dev/null) || \
new_date=$(date -u -r "$new_epoch" '+%Y-%m-%dT23:59:59Z' 2>/dev/null) || \
new_date=$(python3 -c "import datetime;print(datetime.datetime.utcfromtimestamp(${new_epoch}).strftime('%Y-%m-%dT23:59:59Z'))" 2>/dev/null)
new_date=$(_utc_from_epoch "$new_epoch" '+%Y-%m-%dT23:59:59Z')

[ -z "$new_date" ] && { log_error "Failed to compute new expiry date"; return 1; }

Expand Down Expand Up @@ -3394,7 +3390,7 @@ secret_info() {
draw_header "SECRET: ${label}"
echo ""
echo -e " ${BOLD}Status:${NC} $([ "$enabled" = "true" ] && echo "${GREEN}active${NC}" || echo "${RED}disabled${NC}")"
echo -e " ${BOLD}Created:${NC} $(date -d "@${created}" '+%Y-%m-%d %H:%M' 2>/dev/null || date -r "$created" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$created")"
echo -e " ${BOLD}Created:${NC} $(_local_from_epoch "$created" '+%Y-%m-%d %H:%M' || echo "$created")"
[ -n "$notes" ] && echo -e " ${BOLD}Notes:${NC} ${notes}"
local adtag="${SECRETS_AD_TAGS[$idx]:-}"
if [ -n "$adtag" ]; then
Expand Down Expand Up @@ -3608,7 +3604,7 @@ secret_archive_list() {
echo ""
while IFS='|' read -r label key created enabled _mc _mi _q _ex notes; do
[ -z "$label" ] && continue
local date_str; date_str=$(date -d "@${created}" '+%Y-%m-%d' 2>/dev/null || echo "$created")
local date_str; date_str=$(_local_from_epoch "$created" '+%Y-%m-%d') || date_str="$created"
echo -e " ${DIM}${SYM_OK}${NC} ${BOLD}${label}${NC} created: ${date_str}$([ -n "$notes" ] && echo " ${DIM}— ${notes}${NC}")"
done < "$archive_file"
echo ""
Expand Down Expand Up @@ -3874,7 +3870,7 @@ profile_list() {
[ -z "$name" ] && continue
local ts="" date_str="unknown"
[ -f "${PROFILES_DIR}/${name}/.timestamp" ] && ts=$(<"${PROFILES_DIR}/${name}/.timestamp")
[ -n "$ts" ] && date_str=$(date -d "@${ts}" '+%Y-%m-%d %H:%M' 2>/dev/null || date -r "$ts" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$ts")
[ -n "$ts" ] && { date_str=$(_local_from_epoch "$ts" '+%Y-%m-%d %H:%M') || date_str="$ts"; }
echo -e " ${BOLD}${name}${NC} ${DIM}saved: ${date_str}${NC}"
done <<< "$dirs"
echo ""
Expand Down Expand Up @@ -4402,9 +4398,8 @@ secret_check_quota_resets() {
today_day=$(date +%d | sed 's/^0//')
today_month=$(date +%Y-%m)
# Last day of current month (GNU date or BSD fallback)
last_day=$(date -d "$(date +%Y-%m-01) +1 month -1 day" +%d 2>/dev/null | sed 's/^0//')
[ -z "$last_day" ] && last_day=$(date -v1d -v+1m -v-1d +%d 2>/dev/null | sed 's/^0//')
[ -z "$last_day" ] && last_day=31
# 10# forces base-10, so months 08 and 09 are not read as invalid octal.
last_day=$(_last_day_of_month "$(date +%Y)" "$((10#$(date +%m)))")

mkdir -p "$(dirname "$_QUOTA_RESET_LOG")"
touch "$_QUOTA_RESET_LOG"; chmod 600 "$_QUOTA_RESET_LOG"
Expand Down Expand Up @@ -6665,10 +6660,10 @@ run_guest() {

if [[ "${limit_str,,}" =~ ^([0-9]+)h(ours?)?$ ]]; then
local hours="${BASH_REMATCH[1]}"
expires=$(date -u -d "+${hours} hours" "+%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -r $(( $(date +%s) + hours*3600 )) "+%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
expires=$(_utc_now_plus "$((hours * 3600))" "+%Y-%m-%dT%H:%M:%SZ")
elif [[ "${limit_str,,}" =~ ^([0-9]+)d(ays?)?$ ]]; then
local days="${BASH_REMATCH[1]}"
expires=$(date -u -d "+${days} days" "+%Y-%m-%d" 2>/dev/null || date -u -r $(( $(date +%s) + days*86400 )) "+%Y-%m-%d" 2>/dev/null || echo "")
expires=$(_utc_now_plus "$((days * 86400))" "+%Y-%m-%d")
elif [[ "${limit_str,,}" =~ ^([0-9]+)(mb|gb|kb|b)$ ]]; then
quota=$(parse_human_bytes "$limit_str") || {
log_error "Invalid quota format: ${limit_str}"
Expand Down Expand Up @@ -7605,7 +7600,7 @@ run_onboard_wizard() {

local expires=""
if [ "$days" -gt 0 ] 2>/dev/null; then
expires=$(date -d "+${days} days" "+%Y-%m-%d" 2>/dev/null || date -v+${days}d "+%Y-%m-%d" 2>/dev/null || echo "")
expires=$(_utc_now_plus "$((days * 86400))" "+%Y-%m-%d")
fi

log_info "Creating user '${label}' with limits: conns=${conns}, quota=${quota}, expires=${expires:-none}..."
Expand Down Expand Up @@ -9609,6 +9604,62 @@ _iso_to_epoch() {
echo "0"
}

# Format an epoch as a UTC string, in-process.
# The usual `date -d "@epoch" || date -r epoch` chain cannot work on Alpine: GNU takes
# -d, BSD takes -r, and busybox takes neither (its -r reads the timestamp of a *file*).
# Both branches therefore failed and callers silently ended up with an empty expiry.
# bash's printf %()T needs no external command at all; the date branches remain as a
# fallback for bash builds without it.
# $3 is "utc" (default) or "local". Callers that deliberately render local time keep
# doing so — this only changes how the value is obtained, never which timezone it is in.
_epoch_to_date() {
# Callers pass a `date`-style format, where a leading + is a prefix meaning "this is a
# format". printf %()T has no such prefix — leaving it in emits a literal '+'. Strip it
# for printf and put it back for the date fallbacks.
local _epoch="${1:-}" _fmt="${2#+}" _zone="${3:-utc}" _out=""
[ -n "$_epoch" ] && [ -n "$_fmt" ] || return 1
if [ "$_zone" = "utc" ]; then
_out=$(TZ=UTC printf "%(${_fmt})T" "$_epoch" 2>/dev/null) ||
_out=$(date -u -d "@${_epoch}" "+${_fmt}" 2>/dev/null) ||
_out=$(date -u -r "$_epoch" "+${_fmt}" 2>/dev/null) ||
_out=""
else
_out=$(printf "%(${_fmt})T" "$_epoch" 2>/dev/null) ||
_out=$(date -d "@${_epoch}" "+${_fmt}" 2>/dev/null) ||
_out=$(date -r "$_epoch" "+${_fmt}" 2>/dev/null) ||
_out=""
fi
[ -n "$_out" ] || return 1
printf '%s\n' "$_out"
}

_utc_from_epoch() { _epoch_to_date "$1" "$2" utc; }
_local_from_epoch() { _epoch_to_date "$1" "$2" local; }

# Epoch $1 seconds from now, formatted as UTC in $2.
_utc_now_plus() {
_utc_from_epoch "$(($(date +%s) + ${1:-0}))" "${2:-}"
}

# Number of days in a month. `date -d "$(date +%Y-%m-01) +1 month -1 day"` is GNU-only
# and the BSD fallback failed as well, so last_day stayed at a hardcoded 31 — meaning the
# monthly quota reset fired on the wrong day for every month shorter than 31 days.
_last_day_of_month() {
local _year="${1:-}" _month="${2:-}"
case "$_month" in
1 | 3 | 5 | 7 | 8 | 10 | 12) echo 31 ;;
4 | 6 | 9 | 11) echo 30 ;;
2)
if { [ $((_year % 4)) -eq 0 ] && [ $((_year % 100)) -ne 0 ]; } || [ $((_year % 400)) -eq 0 ]; then
echo 29
else
echo 28
fi
;;
*) echo 31 ;;
esac
}

# Get container uptime
get_proxy_uptime() {
if ! is_proxy_running; then
Expand Down Expand Up @@ -10295,7 +10346,12 @@ voucher_redeem() {
local now_iso; now_iso=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
local exp_iso="never"
if [ "${days:-0}" -gt 0 ]; then
exp_iso=$(date -u -d "+${days} days" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u '+%Y-%m-%dT%H:%M:%SZ')
# Never fall back to "now": that silently produced a voucher which expired the
# instant it was redeemed instead of after its stated duration.
exp_iso=$(_utc_now_plus "$((days * 86400))" '+%Y-%m-%dT%H:%M:%SZ') || {
log_error "Failed to compute voucher expiry"
return 1
}
fi
# Mark voucher redeemed atomically
awk -F'|' -v c="$target" -v u="$label" -v t="$now_iso" 'BEGIN{OFS="|"} $1==c && $7=="ACTIVE"{$7="REDEEMED"; $9=u; $10=t} {print}' "$VOUCHERS_FILE" > "${VOUCHERS_FILE}.tmp" && mv "${VOUCHERS_FILE}.tmp" "$VOUCHERS_FILE"
Expand Down
146 changes: 146 additions & 0 deletions tests/test_date_portability.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/bin/bash
# Regression tests for date handling on hosts whose `date` is busybox's applet.
#
# `date -d` is GNU-only, `date -r <epoch>` is BSD-only (busybox's -r reads the timestamp
# of a *file*), and `date -v+1d` is BSD-only. On Alpine every one of those branches
# failed, so guest links, vouchers and onboarding silently produced an empty expiry — and
# the voucher fallback produced *now* rather than now+N days, i.e. a link that never
# expires.
#
# The busybox behaviour is reproduced with a stub on PATH so the regression is caught on
# every platform, not only on Alpine.
set -o pipefail

if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ] || {
[ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]:-0}" -lt 2 ]
}; then
echo "SKIP: bash 4.2+ required (got ${BASH_VERSION:-unknown})" >&2
exit 0
fi

REAL_DATE=$(command -v date)

TEST_TMPDIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'mtp_date_XXXXXX')
INSTALL_DIR="$TEST_TMPDIR/install"
mkdir -p "$INSTALL_DIR"

# A date that behaves like busybox's. The distinction is precisely this: busybox `date`
# exists and formats fine, but it cannot do GNU relative arithmetic. So `-d "@epoch"` and
# `-d "+24 hours"` are rejected, while `-D FMT -d STRING` (busybox's own absolute-parse
# form, which _iso_to_epoch already uses) is accepted. `-r` and `-v` are rejected too —
# busybox has no epoch-reference form and no BSD-style relative form.
FAKEBIN="$TEST_TMPDIR/bin"
mkdir -p "$FAKEBIN"
# Index-based, not shift-based: `shift` would consume "$@" and the delegation below
# would end up calling the real date with no arguments at all.
cat >"$FAKEBIN/date" <<DATE_EOF
#!/bin/bash
_args=("\$@")
_n=\${#_args[@]}
_i=0
while [ "\$_i" -lt "\$_n" ]; do
case "\${_args[\$_i]}" in
-d)
_i=\$((_i + 1))
case "\${_args[\$_i]:-}" in
@* | +*)
echo "date: invalid date '\${_args[\$_i]}'" >&2
exit 1
;;
esac
;;
-r | -v*)
echo "date: unsupported option '\${_args[\$_i]}'" >&2
exit 1
;;
esac
_i=\$((_i + 1))
done
exec "$REAL_DATE" "\$@"
DATE_EOF
chmod +x "$FAKEBIN/date"
PATH="$FAKEBIN:$PATH"
export PATH

MTPROXYMAX_SOURCE_ONLY=true source "$(dirname "${BASH_SOURCE[0]}")/../mtproxymax.sh"
set +e
trap 'rm -rf "$TEST_TMPDIR"' EXIT

TESTS_RUN=0
TESTS_FAILED=0

assert_eq() {
local name="$1" want="$2" got="$3"
TESTS_RUN=$((TESTS_RUN + 1))
if [ "$got" = "$want" ]; then
printf ' PASS %s\n' "$name"
else
printf ' FAIL %s (got=%q want=%q)\n' "$name" "$got" "$want"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
}

assert_match() {
local name="$1" pattern="$2" got="$3"
TESTS_RUN=$((TESTS_RUN + 1))
if [[ "$got" =~ $pattern ]]; then
printf ' PASS %s\n' "$name"
else
printf ' FAIL %s (got=%q does not match %s)\n' "$name" "$got" "$pattern"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
}

check_root() { :; }
load_settings() { :; }
load_secrets() { :; }
log_info() { :; }
log_success() { :; }
log_error() { :; }
secret_add() { return 0; }
secret_edit_note() { return 0; }
reload_proxy_config() { return 0; }

CAPTURED_EXPIRY=""
secret_set_limits() {
CAPTURED_EXPIRY="$5"
return 0
}

echo "date portability tests"

# The stub must really be the date that resolves, or the test proves nothing.
assert_eq "busybox-style date is the one on PATH" "$FAKEBIN/date" "$(command -v date)"
assert_eq "the stub still supports plain formatting" "$(date +%Y)" "$(command date +%Y)"

# --- expiry must be computed, not silently dropped ------------------------------
CAPTURED_EXPIRY=""
run_guest trial24 24h
assert_eq "24h guest creation succeeds despite date lacking -d" "0" "$?"
assert_match "24h guest gets an RFC 3339 expiry" \
'^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$' "$CAPTURED_EXPIRY"

CAPTURED_EXPIRY=""
run_guest trial7d 7d
assert_eq "7d guest creation succeeds despite date lacking -d" "0" "$?"
assert_match "7d guest gets a date-only expiry" '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' "$CAPTURED_EXPIRY"

# Well-formed is not enough — the value must actually be in the future by the right
# amount. Compares against bash's own epoch arithmetic, not against `date`.
CAPTURED_EXPIRY=""
run_guest trial30d 30d
expected=$(TZ=UTC printf '%(%Y-%m-%d)T' "$(($(command date +%s) + 30 * 86400))")
assert_eq "30d expiry lands on the correct calendar day" "$expected" "$CAPTURED_EXPIRY"

# --- month length must not silently fall back to 31 -----------------------------
# The old code left last_day=31 whenever both date branches failed, so a monthly quota
# reset fired on the wrong day for every month shorter than 31 days.
assert_eq "_last_day_of_month 2026-02" "28" "$(_last_day_of_month 2026 2)"
assert_eq "_last_day_of_month 2024-02 (leap)" "29" "$(_last_day_of_month 2024 2)"
assert_eq "_last_day_of_month 2000-02 (400yr leap)" "29" "$(_last_day_of_month 2000 2)"
assert_eq "_last_day_of_month 1900-02 (not a leap year)" "28" "$(_last_day_of_month 1900 2)"
assert_eq "_last_day_of_month 2026-04" "30" "$(_last_day_of_month 2026 4)"
assert_eq "_last_day_of_month 2026-12" "31" "$(_last_day_of_month 2026 12)"

printf '\n%d tests, %d failures\n' "$TESTS_RUN" "$TESTS_FAILED"
[ "$TESTS_FAILED" -eq 0 ]