feat: add Guest Mode isolation with quotas and fix Telegram bot language switching
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
|
||||
NFT_RUNTIME="$ROOT_DIR/tachyon/files/usr/lib/nft/apply.uc"
|
||||
VALIDATOR_RUNTIME="$ROOT_DIR/tachyon/files/usr/lib/config/validator.uc"
|
||||
PARENTAL_QUOTA="$ROOT_DIR/tachyon/files/usr/lib/service/parental_quota.uc"
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
NFT_LOG="$WORK_DIR/nft.log"
|
||||
|
||||
nft_ucode() {
|
||||
ucode -L "$TACHYON_LIB" "$NFT_RUNTIME" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
cat "$NFT_LOG" >&2 2>/dev/null || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local file="$1"
|
||||
local expected="$2"
|
||||
local label="$3"
|
||||
|
||||
grep -Fq "$expected" "$file" || fail "$label: expected '$expected'"
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local file="$1"
|
||||
local unexpected="$2"
|
||||
local label="$3"
|
||||
|
||||
if grep -Fq "$unexpected" "$file"; then
|
||||
fail "$label: unexpected '$unexpected' found"
|
||||
fi
|
||||
}
|
||||
|
||||
mkdir -p "$WORK_DIR/bin"
|
||||
cat >"$WORK_DIR/bin/nft" <<NFT
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
{
|
||||
printf 'nft'
|
||||
for arg in "\$@"; do
|
||||
printf '\t%s' "\$arg"
|
||||
done
|
||||
printf '\n'
|
||||
} >>"$NFT_LOG"
|
||||
exit 0
|
||||
NFT
|
||||
chmod +x "$WORK_DIR/bin/nft"
|
||||
export PATH="$WORK_DIR/bin:$PATH"
|
||||
|
||||
# ─── 1. Selected Mode Test ───────────────────────────────────────────────────
|
||||
cat >"$WORK_DIR/guest_selected.json" <<'JSON'
|
||||
{
|
||||
"guest_mode": [
|
||||
{
|
||||
".name": "guest_mode",
|
||||
"enabled": "1",
|
||||
"mode": "selected",
|
||||
"guest_devices": [ "192.168.1.150", "aa:bb:cc:dd:ee:ff" ],
|
||||
"isolate_lan": "1",
|
||||
"block_router_admin": "1",
|
||||
"start_time": "08:00",
|
||||
"end_time": "22:00",
|
||||
"days": [ "mon", "tue", "wed", "thu", "fri" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
rm -f "$NFT_LOG"
|
||||
nft_ucode nft-add-guest-mode-rules-fixture "$WORK_DIR/guest_selected.json" "tachyon" "tachyon_ifaces" "localv4" "localv6"
|
||||
|
||||
# Router admin drop in guest_input
|
||||
assert_contains "$NFT_LOG" "guest_input ether saddr @tachyon_guest_mac counter drop comment \"tachyon-guest-admin-drop\"" "selected mac admin drop"
|
||||
assert_contains "$NFT_LOG" "guest_input ip saddr @tachyon_guest_ip counter drop comment \"tachyon-guest-admin-drop\"" "selected ip admin drop"
|
||||
|
||||
# Essential services allowed in guest_input
|
||||
assert_contains "$NFT_LOG" "guest_input udp dport { 67, 68 } accept" "DHCP accepted"
|
||||
assert_contains "$NFT_LOG" "guest_input udp dport 53 accept" "DNS UDP accepted"
|
||||
assert_contains "$NFT_LOG" "guest_input tcp dport 53 accept" "DNS TCP accepted"
|
||||
assert_contains "$NFT_LOG" "guest_input icmp type echo-request accept" "ICMP echo accepted"
|
||||
|
||||
# LAN isolation in guest_forward
|
||||
assert_contains "$NFT_LOG" "guest_forward ether saddr @tachyon_guest_mac ip daddr @localv4 counter drop comment \"tachyon-guest-lan-drop\"" "mac lan drop"
|
||||
assert_contains "$NFT_LOG" "guest_forward ip saddr @tachyon_guest_ip ip daddr @localv4 counter drop comment \"tachyon-guest-lan-drop\"" "ip lan drop"
|
||||
|
||||
# Byte accounting counters
|
||||
assert_contains "$NFT_LOG" "guest_forward ether saddr aa:bb:cc:dd:ee:ff counter comment \"tachyon-guest-byte:aa:bb:cc:dd:ee:ff\"" "mac byte counter"
|
||||
assert_contains "$NFT_LOG" "guest_forward ip saddr 192.168.1.150 counter comment \"tachyon-guest-byte:192.168.1.150\"" "ip byte counter"
|
||||
|
||||
# Schedule restriction drops only guest devices outside window
|
||||
assert_contains "$NFT_LOG" "guest_forward ether saddr @tachyon_guest_mac counter drop comment \"guest-time-window-closed\"" "mac time window drop"
|
||||
assert_contains "$NFT_LOG" "guest_forward ip saddr @tachyon_guest_ip counter drop comment \"guest-time-window-closed\"" "ip time window drop"
|
||||
|
||||
# ─── 2. Inverted Mode Test ───────────────────────────────────────────────────
|
||||
cat >"$WORK_DIR/guest_inverted.json" <<'JSON'
|
||||
{
|
||||
"guest_mode": [
|
||||
{
|
||||
".name": "guest_mode",
|
||||
"enabled": "1",
|
||||
"mode": "inverted",
|
||||
"trusted_devices": [ "192.168.1.10", "11:22:33:44:55:66" ],
|
||||
"isolate_lan": "1",
|
||||
"block_router_admin": "1",
|
||||
"start_time": "09:00",
|
||||
"end_time": "21:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
rm -f "$NFT_LOG"
|
||||
nft_ucode nft-add-guest-mode-rules-fixture "$WORK_DIR/guest_inverted.json" "tachyon" "tachyon_ifaces" "localv4" "localv6"
|
||||
|
||||
# Trusted devices return early
|
||||
assert_contains "$NFT_LOG" "guest_input ether saddr @tachyon_trusted_mac return" "trusted mac returns in input"
|
||||
assert_contains "$NFT_LOG" "guest_input ip saddr @tachyon_trusted_ip return" "trusted ip returns in input"
|
||||
assert_contains "$NFT_LOG" "guest_forward ether saddr @tachyon_trusted_mac return" "trusted mac returns in forward"
|
||||
assert_contains "$NFT_LOG" "guest_forward ip saddr @tachyon_trusted_ip return" "trusted ip returns in forward"
|
||||
|
||||
# All non-trusted devices dropped
|
||||
assert_contains "$NFT_LOG" "guest_input iifname @tachyon_ifaces counter drop comment \"tachyon-guest-admin-drop\"" "inverted admin drop"
|
||||
assert_contains "$NFT_LOG" "guest_forward iifname @tachyon_ifaces ip daddr @localv4 counter drop comment \"tachyon-guest-lan-drop\"" "inverted lan drop"
|
||||
assert_contains "$NFT_LOG" "guest_forward iifname @tachyon_ifaces counter comment \"tachyon-guest-traffic\"" "inverted traffic counter"
|
||||
assert_contains "$NFT_LOG" "guest_forward iifname @tachyon_ifaces counter drop comment \"guest-time-window-closed\"" "inverted time window drop"
|
||||
|
||||
# ─── 3. Validator Tests ──────────────────────────────────────────────────────
|
||||
cat >"$WORK_DIR/valid_config.json" <<'JSON'
|
||||
{
|
||||
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
|
||||
"guest_mode": [
|
||||
{
|
||||
".name": "guest_mode",
|
||||
"enabled": "1",
|
||||
"mode": "selected",
|
||||
"guest_devices": [ "192.168.1.50", "aa:bb:cc:dd:ee:ff" ],
|
||||
"daily_time_limit": "120",
|
||||
"daily_traffic_limit": "1024",
|
||||
"start_time": "08:00",
|
||||
"end_time": "20:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
if ! ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/valid_config.json" "{}" >/dev/null 2>&1; then
|
||||
fail "Validator should accept valid guest_mode config"
|
||||
fi
|
||||
|
||||
cat >"$WORK_DIR/invalid_mode.json" <<'JSON'
|
||||
{
|
||||
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
|
||||
"guest_mode": [
|
||||
{
|
||||
".name": "guest_mode",
|
||||
"enabled": "1",
|
||||
"mode": "invalid_mode_name"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_mode.json" "{}" >/dev/null 2>&1; then
|
||||
fail "Validator must reject invalid guest mode"
|
||||
fi
|
||||
|
||||
cat >"$WORK_DIR/invalid_device.json" <<'JSON'
|
||||
{
|
||||
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
|
||||
"guest_mode": [
|
||||
{
|
||||
".name": "guest_mode",
|
||||
"enabled": "1",
|
||||
"mode": "selected",
|
||||
"guest_devices": [ "not-an-ip-or-mac" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_device.json" "{}" >/dev/null 2>&1; then
|
||||
fail "Validator must reject invalid guest device format"
|
||||
fi
|
||||
|
||||
cat >"$WORK_DIR/invalid_quota.json" <<'JSON'
|
||||
{
|
||||
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
|
||||
"guest_mode": [
|
||||
{
|
||||
".name": "guest_mode",
|
||||
"enabled": "1",
|
||||
"daily_time_limit": "99999"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_quota.json" "{}" >/dev/null 2>&1; then
|
||||
fail "Validator must reject out-of-range daily_time_limit"
|
||||
fi
|
||||
|
||||
# ─── 4. Parental Quota Daemon Guest Integration ──────────────────────────────
|
||||
STATE_FILE="$WORK_DIR/quotas.json"
|
||||
UCI_STATE="$WORK_DIR/uci_state"
|
||||
|
||||
cat >"$UCI_STATE" <<'EOF'
|
||||
tachyon.guest_mode=guest_mode
|
||||
tachyon.guest_mode.enabled=1
|
||||
tachyon.guest_mode.mode=selected
|
||||
tachyon.guest_mode.guest_devices=192.168.1.88 aa:bb:cc:dd:ee:ff
|
||||
tachyon.guest_mode.daily_time_limit=60
|
||||
tachyon.guest_mode.daily_traffic_limit=500
|
||||
tachyon.guest_mode.notify=0
|
||||
EOF
|
||||
|
||||
# Prepopulate state file with 59 minutes (1 minute below limit)
|
||||
cat >"$STATE_FILE" <<'EOF'
|
||||
{"day":"2026-09-08","devices":{},"guest_devices":{"192.168.1.88":{"minutes":59,"bytes":0,"blocked":false}}}
|
||||
EOF
|
||||
|
||||
# Mock `date`, `ip`, `logger`
|
||||
cat >"$WORK_DIR/bin/date" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "2026-09-08"
|
||||
EOF
|
||||
chmod +x "$WORK_DIR/bin/date"
|
||||
|
||||
cat >"$WORK_DIR/bin/logger" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$WORK_DIR/bin/logger"
|
||||
|
||||
cat >"$WORK_DIR/bin/ip" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [ "$1" = "neigh" ] && [ "$2" = "show" ]; then
|
||||
echo "192.168.1.88 dev br-lan lladdr aa:bb:cc:dd:ee:ff REACHABLE"
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$WORK_DIR/bin/ip"
|
||||
|
||||
# Run tick with TACHYON_UCI_STATE_FILE
|
||||
PARENTAL_QUOTA_STATE_FILE="$STATE_FILE" TACHYON_UCI_STATE_FILE="$UCI_STATE" ucode -L "$TACHYON_LIB" "$PARENTAL_QUOTA" tick
|
||||
|
||||
# Device should now have reached 60 minutes and be marked blocked
|
||||
grep -Eq '"blocked":\s*true' "$STATE_FILE" || fail "Guest device should be blocked after reaching time limit"
|
||||
|
||||
# Test status command
|
||||
STATUS_JSON="$(PARENTAL_QUOTA_STATE_FILE="$STATE_FILE" TACHYON_UCI_STATE_FILE="$UCI_STATE" ucode -L "$TACHYON_LIB" "$PARENTAL_QUOTA" status)"
|
||||
echo "$STATUS_JSON" | grep -q '"guest_mode"' || fail "Status must contain guest_mode object"
|
||||
echo "$STATUS_JSON" | grep -Eq '"time_limit":\s*60' || fail "Status must contain time_limit:60"
|
||||
echo "$STATUS_JSON" | grep -Eq '"traffic_limit":\s*500' || fail "Status must contain traffic_limit:500"
|
||||
|
||||
printf 'All guest mode isolation, validation, and quota tests passed successfully\n'
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
|
||||
TG="$TACHYON_LIB/service/telegram.uc"
|
||||
I18N="$TACHYON_LIB/service/i18n.uc"
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$TG" ] || fail "telegram.uc not found"
|
||||
[ -f "$I18N" ] || fail "i18n.uc not found"
|
||||
|
||||
# 1. Available languages: Russian must be unconditionally available (not gated by current==ru)
|
||||
grep -Fq '{ code: "ru", label: "Русский", available: true }' "$I18N" ||
|
||||
fail "i18n.uc must make Russian unconditionally available"
|
||||
|
||||
# 2. telegram.uc must not have the broken substr(cmd, 11) for lang_set that chopped 'ru' to 'u'
|
||||
if grep -E 'lang.*substr\(cmd, 11\)' "$TG"; then
|
||||
fail "telegram.uc must NOT use substr(cmd, 11) for language commands"
|
||||
fi
|
||||
|
||||
# 3. /lang_set matching must capture the full language code
|
||||
grep -Fq 'lang_match = match(cmd, /^\/(?:lang_set|lang|language)' "$TG" ||
|
||||
fail "telegram.uc must robustly match /lang_set and /lang commands with arguments"
|
||||
|
||||
# 4. handle_lang_set must rebind t immediately
|
||||
grep -Fq 't = i18n.bind(lang);' "$TG" ||
|
||||
fail "handle_lang_set must rebind translation function 't' immediately"
|
||||
|
||||
# 5. handle_lang_set must update Telegram bot commands menu
|
||||
grep -Fq 'register_bot_commands(token);' "$TG" ||
|
||||
fail "handle_lang_set must call register_bot_commands to update bot menu"
|
||||
|
||||
# 6. /guest and /guest_toggle routes must be defined in telegram.uc
|
||||
grep -Fq 'function view_guest_mode(token, chat_id, msg_id)' "$TG" ||
|
||||
fail "telegram.uc must define view_guest_mode"
|
||||
grep -Fq 'function handle_guest_toggle(token, chat_id, msg_id)' "$TG" ||
|
||||
fail "telegram.uc must define handle_guest_toggle"
|
||||
grep -Fq 'if (cmd == "/guest")' "$TG" ||
|
||||
fail "dispatch_command must route /guest"
|
||||
grep -Fq 'if (cmd == "/guest_toggle")' "$TG" ||
|
||||
fail "dispatch_command must route /guest_toggle"
|
||||
|
||||
# 7. Run ucode evaluation test of i18n language resolution and strings
|
||||
ucode -e '
|
||||
let i18n = loadfile("'"$I18N"'")();
|
||||
if (i18n.resolve_lang("ru") != "ru") exit(1);
|
||||
if (i18n.resolve_lang("en") != "en") exit(2);
|
||||
if (i18n.resolve_lang("invalid") != "en") exit(3);
|
||||
let t_ru = i18n.bind("ru");
|
||||
if (t_ru("lang_saved") != "Язык сохранён!") exit(4);
|
||||
if (t_ru("cmd_guest") != "Гостевой режим") exit(5);
|
||||
let t_en = i18n.bind("en");
|
||||
if (t_en("lang_saved") != "Language saved!") exit(6);
|
||||
if (t_en("cmd_guest") != "Guest mode") exit(7);
|
||||
let langs = i18n.available_languages("en");
|
||||
if (length(langs) != 2 || !langs[0].available || !langs[1].available) exit(8);
|
||||
' || fail "ucode i18n evaluation failed"
|
||||
|
||||
printf 'telegram language and guest route checks passed\n'
|
||||
Reference in New Issue
Block a user