Compare commits

..

No commits in common. "main" and "v1.1.2" have entirely different histories.
main ... v1.1.2

11 changed files with 66 additions and 413 deletions

5
.gitignore vendored
View file

@ -7,10 +7,7 @@ dist/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
# Local build cache (prod/test/old portable folders per version) # Distribution staging folders (built per-version, attached to GitHub Releases)
builds/
# Legacy staging folders (kept for compatibility with old checkouts)
portable-v*/ portable-v*/
# Local backup of release archives (kept locally for history, not in repo) # Local backup of release archives (kept locally for history, not in repo)

View file

@ -6,23 +6,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
## [Unreleased] ## [Unreleased]
## [1.2.1] - 2026-05-18
### Changed
- The `Update available (vX.Y.Z)` hint in the header is now a clickable hyperlink that opens the GitHub releases page (OSC 8 terminal hyperlink). Modern terminals (Windows Terminal, VS Code, WezTerm, most Linux/macOS terminals) render it as a link — `Ctrl+Click` to follow. Older consoles show the plain text, so nothing breaks.
- Russian tagline tightened: dropped the `для инженера` phrase, the wording was carried over from an earlier draft and felt out of place.
## [1.2.0] - 2026-05-18
### Added
- Russian UI translation. On first launch the application asks which language to use (`1) English`, `2) Русский`) and writes the answer to a fresh `config.ini` next to `dhcpsrv.exe`. To change the language later, edit `language = en` / `language = ru` in that file — the comment at the top of the file explains how, in both languages.
- `config.ini` becomes the future home for other settable defaults (pool, lease, server IP) — currently only `[General] language` is consumed.
- Bilingual `README.ru.md` linked from the main `README.md`.
## [1.1.3] - 2026-05-17
### Changed
- Update check no longer interrupts startup with an interactive prompt. If a newer release is available, the header line now shows a quiet `update available (vX.Y.Z)` hint right-aligned in dim grey — no key press required.
### Fixed
- Bumped `__version__` from `1.1.1` to `1.1.3` after the v1.1.2 release packaged a binary that still self-reported as v1.1.1 (the source constant was not bumped before tagging). The source is now once again the single source of truth.
## [1.1.2] - 2026-05-17 ## [1.1.2] - 2026-05-17
### Changed ### Changed
- Dropped the `made by engelgardt` line from the startup banner too — author credit lives in the README only. - Dropped the `made by engelgardt` line from the startup banner too — author credit lives in the README only.
@ -51,11 +34,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
- Scrollback cleared on startup so mouse-wheel doesn't expose pre-launch text. - Scrollback cleared on startup so mouse-wheel doesn't expose pre-launch text.
- MIT licensed. - MIT licensed.
[Unreleased]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.2.1...HEAD [Unreleased]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.0...HEAD
[1.2.1]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.3...v1.2.0
[1.1.3]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.2...v1.1.3
[1.1.2]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.1...v1.1.2
[1.1.1]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.0.0...v1.1.0 [1.1.0]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/Engelgardt23/dhcpsrv/releases/tag/v1.0.0 [1.0.0]: https://github.com/Engelgardt23/dhcpsrv/releases/tag/v1.0.0

View file

@ -3,8 +3,6 @@
[![Latest release](https://img.shields.io/github/v/release/Engelgardt23/dhcpsrv)](https://github.com/Engelgardt23/dhcpsrv/releases/latest) [![Latest release](https://img.shields.io/github/v/release/Engelgardt23/dhcpsrv)](https://github.com/Engelgardt23/dhcpsrv/releases/latest)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
🇺🇸 English | [🇷🇺 Русский](README.ru.md)
A tiny portable **DHCP server** for the laptop of a storage/server engineer. A tiny portable **DHCP server** for the laptop of a storage/server engineer.
One double-click — pick a NIC — done. Live table of clients, ping status, packet counters. No install, no Python required on the target machine. One double-click — pick a NIC — done. Live table of clients, ping status, packet counters. No install, no Python required on the target machine.
@ -45,8 +43,8 @@ The asset is `dhcpsrv-portable-vX.Y.Z.zip` (~12 MB).
└─────────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────┘
┌─ Clients ───────────────────────────────────────────────────────────────┐ ┌─ Clients ───────────────────────────────────────────────────────────────┐
│ # │ IP │ Hostname │ MAC │ Last seen │ Ping │ │ # │ IP │ Hostname │ MAC │ Last seen │ Ping │
│ 1 │ 10.10.10.2 │ server-01 │ a0:c5:f2:13:57:46 │ 17:42:18 │ OK │ │ 1 │ 10.10.10.2 │ vegman-r120 │ a0:c5:f2:13:57:46 │ 17:42:18 │ OK │
│ 2 │ 10.10.10.3 │ server-02 │ 70:b3:d5:11:22:33 │ 17:42:21 │ -- │ │ 2 │ 10.10.10.3 │ vegman-s220 │ 70:b3:d5:11:22:33 │ 17:42:21 │ -- │
└─────────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────┘
┌─ Events ────────────────────────────────────────────────────────────────┐ ┌─ Events ────────────────────────────────────────────────────────────────┐
│ [17:42:18] DISCOVER a0:c5:f2:13:57:46 → OFFER 10.10.10.2 │ │ [17:42:18] DISCOVER a0:c5:f2:13:57:46 → OFFER 10.10.10.2 │
@ -56,7 +54,7 @@ The asset is `dhcpsrv-portable-vX.Y.Z.zip` (~12 MB).
## Typical scenarios ## Typical scenarios
- **Server with shared LOM** — one cable into the BMC/host port, BMC and the host OS both get IPs from this DHCP. - **VEGMAN with shared LOM** — one cable into the BMC/host port, BMC and the host OS both get IPs from this DHCP.
- **8-port switch** — laptop on one port, up to 7 servers on the rest; the 50-address pool covers everyone. - **8-port switch** — laptop on one port, up to 7 servers on the rest; the 50-address pool covers everyone.
- **Direct cable into a dedicated Mgmt port** — single client (the BMC). - **Direct cable into a dedicated Mgmt port** — single client (the BMC).

View file

@ -1,99 +0,0 @@
# dhcpsrv
[![Последний релиз](https://img.shields.io/github/v/release/Engelgardt23/dhcpsrv)](https://github.com/Engelgardt23/dhcpsrv/releases/latest)
[![Лицензия: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[🇺🇸 English](README.md) | 🇷🇺 Русский
Маленький портативный **DHCP-сервер** для ноутбука инженера хранения / серверов.
Двойной клик — выбрал сетевую — готово. Живая таблица клиентов, статус ping, счётчики пакетов. Ничего не устанавливается, Python на целевой машине не нужен.
Сделан под сценарий «воткнул кабель, увидел как BMC получил IP» во время прошивки, восстановления и бенчмарков.
> **Автор: engelgardt.**
---
## Скачать
Последний релиз: [**страница релизов**](https://github.com/Engelgardt23/dhcpsrv/releases/latest).
Архив `dhcpsrv-portable-vX.Y.Z.zip` (~12 МБ).
## Запуск
1. Распакуй куда угодно.
2. Двойной клик по `dhcpsrv.exe`.
3. **При первом запуске** программа спросит язык интерфейса (1 — English, 2 — Русский). Ответ запишется в `config.ini` рядом с exe — потом можно поменять руками.
4. Подтверди UAC (admin нужен, чтобы занять UDP/67 и переключить адаптер на статический IP).
5. Выбери сетевой адаптер, воткнутый в твой сервер или коммутатор — это единственный вопрос.
6. `Ctrl+C` — стоп. Спросит, вернуть ли адаптер обратно в DHCP.
## Настройки по умолчанию (никаких других вопросов)
| Параметр | Значение |
|---|---|
| IP сервера | `10.10.10.1/24` |
| Пул | `10.10.10.2 .. 10.10.10.51` (50 адресов) |
| Lease | `7200 с` (2 часа — переживёт долгий стресс-тест) |
| Опция TFTP | IP сервера (BMC сразу увидит твой Tftpd32) |
## Что на экране
```
┌─ dhcpsrv v1.2.0 ────────────────────────────────────────────────────────┐
│ Сервер: 10.10.10.1/255.255.255.0 Пул: 10.10.10.210.10.10.51 … │
│ Аренды: 3/50 Пакетов: 47 DISCOVER: 12 REQUEST: 11 RELEASE: 0 │
└─────────────────────────────────────────────────────────────────────────┘
┌─ Клиенты ───────────────────────────────────────────────────────────────┐
│ # │ IP │ Имя хоста │ MAC │ Последний │ Пинг │
│ 1 │ 10.10.10.2 │ server-01 │ a0:c5:f2:13:57:46 │ 17:42:18 │ OK │
│ 2 │ 10.10.10.3 │ server-02 │ 70:b3:d5:11:22:33 │ 17:42:21 │ -- │
└─────────────────────────────────────────────────────────────────────────┘
┌─ События ───────────────────────────────────────────────────────────────┐
│ [17:42:18] DISCOVER a0:c5:f2:13:57:46 → OFFER 10.10.10.2 │
│ [17:42:18] REQUEST a0:c5:f2:13:57:46 → ACK 10.10.10.2 │
└─────────────────────────────────────────────────────────────────────────┘
```
## Типичные сценарии
- **Сервер с общим LOM** — один кабель в порт BMC/host, и BMC и хост-ОС получают IP с этого DHCP.
- **8-портовый свитч** — ноут на одном порту, до 7 серверов на остальных; пул из 50 адресов покрывает всех.
- **Прямой кабель в выделенный Mgmt-порт** — один клиент (BMC).
## Совместимость
- Windows 10 / 11.
- В списке адаптеров отфильтровано всё лишнее (Wi-Fi, Cisco AnyConnect, Hyper-V, VMware, VirtualBox, TAP/TUN, WireGuard, OpenVPN, Tailscale, ZeroTier).
- Имена адаптеров с пробелами или не-ASCII экранируются корректно для `netsh`.
## Конфиг
При первом запуске рядом с `dhcpsrv.exe` появится `config.ini`:
```ini
# Чтобы сменить язык интерфейса, измените 'language' ниже.
# Допустимые значения: en, ru
[General]
language = ru
```
В будущих релизах сюда же переедут пул, lease и IP сервера — пока что переменная одна.
## Заметки
- На машине ничего не устанавливается. Удалить — стереть папку.
- UAC спросит каждый раз. (Если хочешь убрать на *своей* машине — пропусти `dhcpsrv.exe` через Scheduled Task с галкой «Run with highest privileges» и запускай через `schtasks /run /tn dhcpsrv`.)
- Если в Tftpd32 включён модуль DHCP — отключи, UDP/67 окажется занят.
- Lease по умолчанию 7200 с; клиент продлевает в середине срока. Если нужно «навсегда», исходник поддерживает `2147483647` — пересобери из исходников.
## Сборка из исходников
```
python -m pip install rich pyinstaller
python -m PyInstaller --onefile --uac-admin --console --name dhcpsrv dhcpsrv-launcher.py
```
## Лицензия
MIT — см. [LICENSE](LICENSE).

View file

@ -6,5 +6,5 @@ The single source of truth for the project version. Bump this before tagging
a release; CI reads the tag, the code reads this constant. a release; CI reads the tag, the code reads this constant.
""" """
__version__ = "1.2.2" __version__ = "1.1.1"
GITHUB_REPO = "engel/dhcpsrv" # на Forgejo (git.engelgardt23.ru) GITHUB_REPO = "Engelgardt23/dhcpsrv"

View file

@ -9,67 +9,47 @@ import threading
from rich.console import Console from rich.console import Console
from rich.prompt import Confirm, Prompt from rich.prompt import Confirm, Prompt
from rich.table import Table
from . import __version__, GITHUB_REPO from . import __version__
from .platform_win import enable_vt, require_admin from .platform_win import enable_vt, require_admin
from .update_check import check_for_update from .update_check import check_for_update
from .network import list_adapters, set_static_ip, revert_to_dhcp from .network import list_adapters, set_static_ip, revert_to_dhcp
from .dhcp import DhcpConfig, DhcpServer, now_s from .dhcp import DhcpConfig, DhcpServer, now_s
from .ui import Ui from .ui import Ui
from .config import load_config
from .i18n import set_language, t
def _select_nic(console: Console) -> dict | None: def _select_nic(console: Console) -> dict | None:
console.rule(f"[bold cyan]{t('available_adapters')}") console.rule("[bold cyan]Available adapters")
adapters = list_adapters() adapters = list_adapters()
if not adapters: if not adapters:
console.print(f"[red]{t('no_adapters')}[/]") console.print("[red]No suitable wired adapters found.[/]")
return None return None
for i, a in enumerate(adapters, 1): for i, a in enumerate(adapters, 1):
ip = a.get("IPv4") or "" ip = a.get("IPv4") or ""
console.print(f" {i}) [{a['Status']}] {a['Name']} ({a['Description']}) {ip}") console.print(f" {i}) [{a['Status']}] {a['Name']} ({a['Description']}) {ip}")
while True: while True:
s = Prompt.ask(t("select_adapter")).strip() s = Prompt.ask("Select adapter number").strip()
if s.isdigit() and 1 <= int(s) <= len(adapters): if s.isdigit() and 1 <= int(s) <= len(adapters):
return adapters[int(s) - 1] return adapters[int(s) - 1]
console.print(f"[red]{t('invalid_selection')}[/]") console.print("[red]Invalid selection.[/]")
def main() -> None: def main() -> None:
enable_vt() enable_vt()
# Language prompt (writes config.ini on first run) happens BEFORE admin
# elevation so the user does not have to answer it twice after the UAC
# bounce.
cfg_data = load_config()
set_language(cfg_data["language"])
require_admin() require_admin()
console = Console(log_path=False) console = Console(log_path=False)
console.print(f"[bold cyan]dhcpsrv v{__version__}[/] - portable laptop-side DHCP server")
title = f"[bold cyan]dhcpsrv v{__version__}[/] {t('tagline')}"
latest = check_for_update()
if latest:
release_url = f"https://git.engelgardt23.ru/{GITHUB_REPO}/releases/latest"
notice = t("update_available", tag=latest)
header = Table.grid(expand=True)
header.add_column(justify="left", ratio=1)
header.add_column(justify="right")
header.add_row(title, f"[dim][link={release_url}]{notice}[/link][/]")
console.print(header)
else:
console.print(title)
console.print() console.print()
check_for_update(console)
nic = _select_nic(console) nic = _select_nic(console)
if not nic: if not nic:
input(t("press_enter")); return input("Press Enter to exit"); return
cfg = DhcpConfig.with_defaults() cfg = DhcpConfig.with_defaults()
console.print(f"[yellow]{t('setting_nic', name=nic['Name'], ip=cfg.server_ip, mask=cfg.netmask)}[/]") console.print(f"[yellow]Setting {nic['Name']}{cfg.server_ip} / {cfg.netmask} ...[/]")
set_static_ip(nic["Name"], cfg.server_ip, cfg.netmask) set_static_ip(nic["Name"], cfg.server_ip, cfg.netmask)
# Wire server <-> ui through callbacks so neither imports the other. # Wire server <-> ui through callbacks so neither imports the other.
@ -85,15 +65,15 @@ def main() -> None:
stop.set() stop.set()
try: try:
print() print()
print(t("shutting_down", ts=now_s())) print(f"[{now_s()}] Shutting down...")
try: try:
if Confirm.ask(t("revert_nic", name=nic["Name"]), default=False): if Confirm.ask(f"Revert {nic['Name']} back to DHCP?", default=False):
revert_to_dhcp(nic["Name"]) revert_to_dhcp(nic["Name"])
print(t("nic_reverted")) print("NIC reverted to DHCP")
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
pass pass
finally: finally:
input(t("press_enter")) input("Press Enter to exit")
sys.exit(0) sys.exit(0)
signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGINT, shutdown)

View file

@ -1,94 +0,0 @@
"""
config.ini handling next to the executable.
On first run the file does not exist we ask the user which language to use
and write the answer alongside the exe. On every subsequent run we just read
it. The .ini has a leading bilingual comment explaining how to change values
by editing the file directly.
"""
from __future__ import annotations
import configparser
import sys
from pathlib import Path
SUPPORTED_LANGS = ("en", "ru")
DEFAULT_LANG = "en"
CONFIG_HEADER = """\
# ---------------------------------------------------------------------------
# dhcpsrv configuration
#
# To change the interface language, edit the 'language' value below.
# Valid values: en, ru
#
# Чтобы сменить язык интерфейса, измените значение 'language' ниже.
# Допустимые значения: en, ru
# ---------------------------------------------------------------------------
"""
def app_dir() -> Path:
"""Directory holding the running executable (or source folder when run via python)."""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
def config_path() -> Path:
return app_dir() / "config.ini"
def _ask_language() -> str:
"""First-run prompt. Stdin is always available in console apps, no Rich here
yet (we run before the main console is set up)."""
print()
print("Select language / Выберите язык:")
print(" 1) English")
print(" 2) Русский")
while True:
try:
choice = input("> ").strip()
except (EOFError, KeyboardInterrupt):
return DEFAULT_LANG
if choice == "1":
return "en"
if choice == "2":
return "ru"
print("Please enter 1 or 2 / Введите 1 или 2")
def _write_config(lang: str) -> None:
path = config_path()
cp = configparser.ConfigParser()
cp["General"] = {"language": lang}
with path.open("w", encoding="utf-8") as f:
f.write(CONFIG_HEADER)
cp.write(f)
def load_config() -> dict:
"""Return the active configuration dict. Side-effect: creates config.ini on
first run after prompting the user."""
path = config_path()
if not path.exists():
lang = _ask_language()
try:
_write_config(lang)
except OSError:
# read-only location — fall back to in-memory default
pass
return {"language": lang}
cp = configparser.ConfigParser()
try:
cp.read(path, encoding="utf-8")
except (configparser.Error, OSError):
return {"language": DEFAULT_LANG}
lang = (cp.get("General", "language", fallback=DEFAULT_LANG) or DEFAULT_LANG).strip().lower()
if lang not in SUPPORTED_LANGS:
lang = DEFAULT_LANG
return {"language": lang}

View file

@ -15,7 +15,6 @@ from datetime import datetime
from typing import Callable, Optional from typing import Callable, Optional
from .network import ping_one from .network import ping_one
from .i18n import t as _t
# ---------- helpers ---------- # ---------- helpers ----------
@ -175,8 +174,8 @@ class DhcpServer:
try: try:
s.bind(("0.0.0.0", 67)) s.bind(("0.0.0.0", 67))
except OSError as e: except OSError as e:
self.log(f"[bold red]{_t('bind_failed')}[/] {e}") self.log(f"[bold red]bind UDP/67 failed:[/] {e}")
self.log(f"[yellow]{_t('bind_hint')}[/]") self.log("[yellow]Another DHCP service (Tftpd32 DHCP, ICS, Windows DHCP) may be running.[/]")
stop.set() stop.set()
return return
@ -201,7 +200,7 @@ class DhcpServer:
self.stats["discovers"] += 1 self.stats["discovers"] += 1
ipn = self.alloc_ip(mac) ipn = self.alloc_ip(mac)
if ipn is None: if ipn is None:
self.log(f"[dim][{now_s()}][/] [red]DISCOVER[/] {mac} → [red]{_t('pool_exhausted')}[/]") self.log(f"[dim][{now_s()}][/] [red]DISCOVER[/] {mac} → [red]POOL EXHAUSTED[/]")
continue continue
self.touch_client(mac, ipn, host) self.touch_client(mac, ipn, host)
s.sendto(self.build_reply(data, 2, ipn), ("255.255.255.255", 68)) s.sendto(self.build_reply(data, 2, ipn), ("255.255.255.255", 68))

View file

@ -1,115 +0,0 @@
"""
Tiny in-memory translation table. We do not need .po/.mo machinery for a
two-language CLI tool a flat dict per language is enough.
Usage:
from .i18n import t, set_language
set_language("ru")
print(t("no_adapters"))
`t(key, **params)` performs `.format(**params)` on the returned string, so
placeholders work the same way as f-strings.
"""
from __future__ import annotations
_lang = "en"
STRINGS: dict[str, dict[str, str]] = {
"en": {
# app.py / startup
"tagline": "- portable laptop-side DHCP server",
"update_available": "Update available ({tag})",
"available_adapters": "Available adapters",
"no_adapters": "No suitable wired adapters found.",
"select_adapter": "Select adapter number",
"invalid_selection": "Invalid selection.",
"press_enter": "Press Enter to exit",
"setting_nic": "Setting {name}{ip} / {mask} ...",
"shutting_down": "[{ts}] Shutting down...",
"revert_nic": "Revert {name} back to DHCP?",
"nic_reverted": "NIC reverted to DHCP",
# dhcp.py / server messages
"bind_failed": "bind UDP/67 failed:",
"bind_hint": "Another DHCP service (Tftpd32 DHCP, ICS, Windows DHCP) may be running.",
"pool_exhausted": "POOL EXHAUSTED",
# ui.py / header & panels
"panel_server": "Server",
"panel_pool": "Pool",
"panel_lease": "Lease",
"panel_tftp": "TFTP",
"panel_leases": "Leases",
"panel_pkts": "Pkts",
"panel_ctrlc": "Ctrl+C to stop",
"col_ip": "IP",
"col_host": "Hostname",
"col_mac": "MAC",
"col_last": "Last seen",
"col_ping": "Ping",
"no_clients": "(no clients yet)",
"more_clients": "(+{n} more — enlarge the window)",
"events_title": "Events",
"clients_title": "Clients",
"no_events": "(no events yet)",
},
"ru": {
"tagline": "— портативный DHCP-сервер",
"update_available": "Доступно обновление ({tag})",
"available_adapters": "Доступные адаптеры",
"no_adapters": "Подходящие проводные адаптеры не найдены.",
"select_adapter": "Введите номер адаптера",
"invalid_selection": "Неверный выбор.",
"press_enter": "Нажмите Enter для выхода",
"setting_nic": "Назначаю {name}{ip} / {mask} ...",
"shutting_down": "[{ts}] Завершение работы...",
"revert_nic": "Вернуть {name} обратно в режим DHCP?",
"nic_reverted": "Адаптер возвращён в режим DHCP",
"bind_failed": "не удалось занять UDP/67:",
"bind_hint": "Возможно, уже запущен другой DHCP (модуль DHCP в Tftpd32, ICS, Windows DHCP).",
"pool_exhausted": "ПУЛ ИСЧЕРПАН",
"panel_server": "Сервер",
"panel_pool": "Пул",
"panel_lease": "Аренда",
"panel_tftp": "TFTP",
"panel_leases": "Аренды",
"panel_pkts": "Пакетов",
"panel_ctrlc": "Ctrl+C — выход",
"col_ip": "IP",
"col_host": "Имя хоста",
"col_mac": "MAC",
"col_last": "Последний",
"col_ping": "Пинг",
"no_clients": "(клиентов пока нет)",
"more_clients": "(ещё +{n} — увеличьте окно)",
"events_title": "События",
"clients_title": "Клиенты",
"no_events": "(пока пусто)",
},
}
def set_language(lang: str) -> None:
global _lang
if lang in STRINGS:
_lang = lang
def language() -> str:
return _lang
def t(key: str, **params) -> str:
"""Translate `key` for the active language; fall back to English if a key is
missing in the chosen language. Apply `.format(**params)` for placeholders."""
s = STRINGS.get(_lang, {}).get(key) or STRINGS["en"].get(key, key)
if params:
try:
return s.format(**params)
except (KeyError, IndexError):
return s
return s

View file

@ -19,7 +19,6 @@ from rich.text import Text
from . import __version__ from . import __version__
from .dhcp import DhcpServer, int2ip from .dhcp import DhcpServer, int2ip
from .i18n import t as _t
# Fixed-size layout slots — used to compute the clients-table fit. # Fixed-size layout slots — used to compute the clients-table fit.
@ -55,27 +54,27 @@ class Ui:
cfg = self.server.cfg cfg = self.server.cfg
body = ( body = (
f"[bold cyan]dhcpsrv v{__version__}[/]\n" f"[bold cyan]dhcpsrv v{__version__}[/]\n"
f"{_t('panel_server')}: [bold]{cfg.server_ip}[/]/{cfg.netmask} " f"Server: [bold]{cfg.server_ip}[/]/{cfg.netmask} "
f"{_t('panel_pool')}: [bold]{int2ip(cfg.pool[0])}{int2ip(cfg.pool[-1])}[/] " f"Pool: [bold]{int2ip(cfg.pool[0])}{int2ip(cfg.pool[-1])}[/] "
f"{_t('panel_lease')}: [bold]{cfg.lease}s[/] " f"Lease: [bold]{cfg.lease}s[/] "
f"{_t('panel_tftp')}: [bold]{cfg.tftp}[/]\n" f"TFTP: [bold]{cfg.tftp}[/]\n"
f"{_t('panel_leases')}: [bold]{leased}/{len(cfg.pool)}[/] " f"Leases: [bold]{leased}/{len(cfg.pool)}[/] "
f"{_t('panel_pkts')}: [dim]{st['packets']}[/] " f"Pkts: [dim]{st['packets']}[/] "
f"DISCOVER: [cyan]{st['discovers']}[/] " f"DISCOVER: [cyan]{st['discovers']}[/] "
f"REQUEST: [green]{st['requests']}[/] " f"REQUEST: [green]{st['requests']}[/] "
f"RELEASE: [yellow]{st['releases']}[/] " f"RELEASE: [yellow]{st['releases']}[/] "
f"[dim]{_t('panel_ctrlc')}[/]" f"[dim]Ctrl+C to stop[/]"
) )
return Panel(body, border_style="cyan") return Panel(body, border_style="cyan")
def _render_table(self) -> Table: def _render_table(self) -> Table:
tbl = Table(expand=True, header_style="bold") t = Table(expand=True, header_style="bold")
tbl.add_column("#", style="dim", width=3, justify="right") t.add_column("#", style="dim", width=3, justify="right")
tbl.add_column(_t("col_ip"), width=16) t.add_column("IP", width=16)
tbl.add_column(_t("col_host"), min_width=10) t.add_column("Hostname", min_width=10)
tbl.add_column(_t("col_mac"), width=19) t.add_column("MAC", width=19)
tbl.add_column(_t("col_last"), style="dim", width=10) t.add_column("Last seen", style="dim", width=10)
tbl.add_column(_t("col_ping"), width=6, justify="center") t.add_column("Ping", width=6, justify="center")
with self.server.lock: with self.server.lock:
rows = sorted(self.server.clients.items(), key=lambda kv: kv[1]["ip_int"]) rows = sorted(self.server.clients.items(), key=lambda kv: kv[1]["ip_int"])
@ -86,12 +85,12 @@ class Ui:
rows = rows[: avail - 1] # leave one slot for the "(+N more)" marker rows = rows[: avail - 1] # leave one slot for the "(+N more)" marker
if not rows: if not rows:
tbl.add_row("", "", _t("no_clients"), "", "", "") t.add_row("", "", "(no clients yet)", "", "", "")
else: else:
for i, (mac, c) in enumerate(rows, 1): for i, (mac, c) in enumerate(rows, 1):
ping = (Text("OK", style="bold green") ping = (Text("OK", style="bold green")
if c.get("ping_ok") else Text("--", style="bold red")) if c.get("ping_ok") else Text("--", style="bold red"))
tbl.add_row( t.add_row(
str(i), str(i),
int2ip(c["ip_int"]), int2ip(c["ip_int"]),
c.get("host") or "", c.get("host") or "",
@ -100,20 +99,20 @@ class Ui:
ping, ping,
) )
if overflow: if overflow:
tbl.add_row("", "", f"[dim]{_t('more_clients', n=overflow)}[/]", "", "", "") t.add_row("", "", f"[dim](+{overflow} more — enlarge the window)[/]", "", "", "")
return tbl return t
def _render_events(self) -> Panel: def _render_events(self) -> Panel:
with self.events_lock: with self.events_lock:
last = list(self.events)[-20:] last = list(self.events)[-20:]
body = "\n".join(last) if last else f"[dim]{_t('no_events')}[/]" body = "\n".join(last) if last else "[dim](no events yet)[/]"
return Panel(body, title=_t("events_title"), border_style="dim") return Panel(body, title="Events", border_style="dim")
def _render_screen(self) -> Layout: def _render_screen(self) -> Layout:
layout = Layout() layout = Layout()
layout.split_column( layout.split_column(
Layout(self._render_header(), name="hdr", size=HEADER_LINES), Layout(self._render_header(), name="hdr", size=HEADER_LINES),
Layout(Panel(self._render_table(), title=_t("clients_title"), border_style="cyan"), name="tbl"), Layout(Panel(self._render_table(), title="Clients", border_style="cyan"), name="tbl"),
Layout(self._render_events(), name="evt", size=EVENTS_LINES), Layout(self._render_events(), name="evt", size=EVENTS_LINES),
) )
return layout return layout

View file

@ -2,13 +2,16 @@
Auto-update check. Auto-update check.
On startup, ask GitHub for the latest release tag. If it's newer than the On startup, ask GitHub for the latest release tag. If it's newer than the
local `__version__`, return the tag string so the caller can show a quiet local `__version__`, ask the user whether to open the download page in a
hint in the header. Silent on any error (offline, rate-limit, etc.). browser. Silent on any error (offline, rate-limit, etc.)."""
"""
from __future__ import annotations from __future__ import annotations
import json import json
import urllib.request import urllib.request
import webbrowser
from rich.console import Console
from rich.prompt import Confirm
from . import __version__, GITHUB_REPO from . import __version__, GITHUB_REPO
@ -24,20 +27,27 @@ def _parse_version(s: str) -> tuple[int, int, int]:
return (0, 0, 0) return (0, 0, 0)
def check_for_update() -> str | None: def check_for_update(console: Console) -> None:
"""Return the latest release tag (e.g. 'v1.2.0') if it is newer than the
currently running version. Returns None when up to date, offline, or on
any error the caller decides how (or whether) to render the hint."""
try: try:
url = f"https://git.engelgardt23.ru/api/v1/repos/{GITHUB_REPO}/releases/latest" url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
req = urllib.request.Request(url, headers={ req = urllib.request.Request(url, headers={
"Accept": "application/vnd.github+json",
"User-Agent": f"dhcpsrv/{__version__}", "User-Agent": f"dhcpsrv/{__version__}",
}) })
with urllib.request.urlopen(req, timeout=3) as r: with urllib.request.urlopen(req, timeout=3) as r:
data = json.loads(r.read().decode("utf-8", errors="replace")) data = json.loads(r.read().decode("utf-8", errors="replace"))
latest = (data.get("tag_name") or "").strip() latest = (data.get("tag_name") or "").strip()
if latest and _parse_version(latest) > _parse_version(__version__): page = data.get("html_url") or f"https://github.com/{GITHUB_REPO}/releases/latest"
return latest
if _parse_version(latest) > _parse_version(__version__):
console.rule("[bold yellow]Update available")
console.print(f"Current: [dim]v{__version__}[/] Latest: [bold green]{latest}[/]")
try:
if Confirm.ask("Open the download page in your browser?", default=True):
webbrowser.open(page)
except (EOFError, KeyboardInterrupt):
pass
console.print()
except Exception: except Exception:
# Offline / API error — silent on purpose.
pass pass
return None