Compare commits
13 Commits
v1.1.3
...
4322393de7
| Author | SHA1 | Date | |
|---|---|---|---|
| 4322393de7 | |||
| 215e264e5f | |||
| 99573cba5c | |||
| ac1c40d754 | |||
| 9ef788fe5b | |||
| 27304992a0 | |||
| 07b93d1382 | |||
| 816cc9a459 | |||
| 88f282f1e0 | |||
| 33c28128b8 | |||
| 97fed974fc | |||
| e8910f0f02 | |||
| bba380c8ef |
@@ -8,24 +8,30 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
# Self-contained: no external (GitHub-hosted) actions. Runs on a self-hosted
|
||||||
|
# Windows runner (label `windows`) that already has Python 3.12 on PATH.
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: windows-latest
|
runs-on: windows
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout repository at tag
|
||||||
|
shell: pwsh
|
||||||
- name: Set up Python
|
env:
|
||||||
uses: actions/setup-python@v5
|
TOKEN: ${{ github.token }}
|
||||||
with:
|
run: |
|
||||||
python-version: '3.12'
|
$base = "${{ github.server_url }}" -replace '^https://', ''
|
||||||
|
$url = "https://oauth2:$env:TOKEN@$base/${{ github.repository }}.git"
|
||||||
|
git clone --depth 1 --branch "$env:GITHUB_REF_NAME" $url .
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: python -m pip install --upgrade pip pyinstaller rich
|
run: python -m pip install --upgrade pip pyinstaller rich
|
||||||
|
|
||||||
- name: Resolve version from tag
|
- name: Resolve version from tag
|
||||||
id: ver
|
id: ver
|
||||||
shell: bash
|
shell: pwsh
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
run: |
|
||||||
|
$v = "$env:GITHUB_REF_NAME" -replace '^v', ''
|
||||||
|
"version=$v" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||||
|
|
||||||
- name: Build executable
|
- name: Build executable
|
||||||
run: python -m PyInstaller --onefile --uac-admin --console --name dhcpsrv --icon assets/icon.ico --paths src dhcpsrv-launcher.py
|
run: python -m PyInstaller --onefile --uac-admin --console --name dhcpsrv --icon assets/icon.ico --paths src dhcpsrv-launcher.py
|
||||||
@@ -39,7 +45,6 @@ jobs:
|
|||||||
Copy-Item dist/dhcpsrv.exe $folder/
|
Copy-Item dist/dhcpsrv.exe $folder/
|
||||||
@"
|
@"
|
||||||
dhcpsrv v$ver - portable edition
|
dhcpsrv v$ver - portable edition
|
||||||
made by engelgardt
|
|
||||||
|
|
||||||
Minimal laptop-side DHCP server for storage/server engineers.
|
Minimal laptop-side DHCP server for storage/server engineers.
|
||||||
|
|
||||||
@@ -71,10 +76,19 @@ jobs:
|
|||||||
"$hash $zip" | Out-File -FilePath "$zip.sha256" -Encoding ASCII -NoNewline
|
"$hash $zip" | Out-File -FilePath "$zip.sha256" -Encoding ASCII -NoNewline
|
||||||
Get-Content "$zip.sha256"
|
Get-Content "$zip.sha256"
|
||||||
|
|
||||||
- name: Create release
|
- name: Publish Gitea release
|
||||||
uses: softprops/action-gh-release@v2
|
shell: pwsh
|
||||||
with:
|
env:
|
||||||
files: |
|
TOKEN: ${{ github.token }}
|
||||||
dhcpsrv-portable-v${{ steps.ver.outputs.version }}.zip
|
run: |
|
||||||
dhcpsrv-portable-v${{ steps.ver.outputs.version }}.zip.sha256
|
$ver = '${{ steps.ver.outputs.version }}'
|
||||||
generate_release_notes: true
|
$tag = "v$ver"
|
||||||
|
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||||
|
$hdr = @{ Authorization = "token $env:TOKEN" }
|
||||||
|
$body = @{ tag_name = $tag; name = $tag; draft = $false; prerelease = $false } | ConvertTo-Json
|
||||||
|
$rel = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $hdr -ContentType 'application/json' -Body $body
|
||||||
|
$rid = $rel.id
|
||||||
|
foreach ($f in @("dhcpsrv-portable-v$ver.zip", "dhcpsrv-portable-v$ver.zip.sha256")) {
|
||||||
|
curl.exe -s -H "Authorization: token $env:TOKEN" -F "attachment=@$f" "$api/releases/$rid/assets?name=$f" | Out-Null
|
||||||
|
Write-Host "uploaded $f"
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
name: Bug report
|
|
||||||
description: Something doesn't work as expected
|
|
||||||
labels: ["bug"]
|
|
||||||
body:
|
|
||||||
- type: input
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: Version
|
|
||||||
description: Visible in the startup banner.
|
|
||||||
placeholder: v1.1.0
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: steps
|
|
||||||
attributes:
|
|
||||||
label: Steps to reproduce
|
|
||||||
placeholder: |
|
|
||||||
1. ...
|
|
||||||
2. ...
|
|
||||||
3. ...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: expected
|
|
||||||
attributes:
|
|
||||||
label: What you expected to happen
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: actual
|
|
||||||
attributes:
|
|
||||||
label: What actually happened
|
|
||||||
description: Paste any error output verbatim. Screenshots are welcome.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: os
|
|
||||||
attributes:
|
|
||||||
label: Windows version
|
|
||||||
placeholder: e.g. Windows 11 24H2
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: network
|
|
||||||
attributes:
|
|
||||||
label: Network setup (if relevant)
|
|
||||||
placeholder: e.g. USB Realtek NIC to a server's BMC port via an 8-port switch
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: extra
|
|
||||||
attributes:
|
|
||||||
label: Anything else?
|
|
||||||
description: Workarounds tried, related links, etc.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
blank_issues_enabled: false
|
|
||||||
contact_links:
|
|
||||||
- name: Security vulnerability
|
|
||||||
url: https://github.com/Engelgardt23/dhcpsrv/security/advisories/new
|
|
||||||
about: Please report security issues privately via GitHub Security Advisories — not as a public issue.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
name: Feature request
|
|
||||||
description: Suggest a new feature or an improvement
|
|
||||||
labels: ["enhancement"]
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: motivation
|
|
||||||
attributes:
|
|
||||||
label: What's the use case?
|
|
||||||
description: What are you trying to do, and why is the current behavior not enough?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: proposal
|
|
||||||
attributes:
|
|
||||||
label: Proposed solution
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: alternatives
|
|
||||||
attributes:
|
|
||||||
label: Alternatives considered
|
|
||||||
+4
-1
@@ -7,7 +7,10 @@ dist/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
||||||
# Distribution staging folders (built per-version, attached to GitHub Releases)
|
# Local build cache (prod/test/old portable folders per version)
|
||||||
|
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)
|
||||||
|
|||||||
+27
-8
@@ -6,6 +6,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.3] - 2026-06-01
|
||||||
|
### Changed
|
||||||
|
- Project is now fully self-hosted on Gitea (`git.engelgardt23.ru`): update-check, links and badges no longer reference GitHub.
|
||||||
|
- CI moved to self-contained Gitea Actions (no GitHub-hosted actions); releases are built on a self-hosted Windows runner and published to the local instance.
|
||||||
|
|
||||||
|
## [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 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
|
## [1.1.3] - 2026-05-17
|
||||||
### Changed
|
### 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.
|
- 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.
|
||||||
@@ -24,11 +40,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|||||||
|
|
||||||
## [1.1.0] - 2026-05-16
|
## [1.1.0] - 2026-05-16
|
||||||
### Added
|
### Added
|
||||||
- Auto-update check on startup. Polls GitHub `/releases/latest` with a 3-second timeout. If a newer version is available, prints a yellow notice and offers to open the download page in your browser. Silent on offline / API errors.
|
- Auto-update check on startup. Polls the Gitea instance `/releases/latest` with a 3-second timeout. If a newer version is available, prints a yellow notice and offers to open the download page in your browser. Silent on offline / API errors.
|
||||||
|
|
||||||
## [1.0.0] - 2026-05-16
|
## [1.0.0] - 2026-05-16
|
||||||
### Added
|
### Added
|
||||||
- First public release on GitHub.
|
- First public release.
|
||||||
- Single portable `.exe` (~12 MB) — no Python required on target machines.
|
- Single portable `.exe` (~12 MB) — no Python required on target machines.
|
||||||
- Full-screen TUI built on `rich`: header with server config + live counters (Leases / Pkts / DISCOVER / REQUEST / RELEASE), clients table (`#`, IP, Hostname, MAC, Last seen, Ping), scrolling events panel.
|
- Full-screen TUI built on `rich`: header with server config + live counters (Leases / Pkts / DISCOVER / REQUEST / RELEASE), clients table (`#`, IP, Hostname, MAC, Last seen, Ping), scrolling events panel.
|
||||||
- Hardcoded sensible defaults: server `10.10.10.1/24`, pool `10.10.10.2..10.10.10.51` (50 addresses), lease `7200 s`, TFTP option (66/150) = server IP.
|
- Hardcoded sensible defaults: server `10.10.10.1/24`, pool `10.10.10.2..10.10.10.51` (50 addresses), lease `7200 s`, TFTP option (66/150) = server IP.
|
||||||
@@ -40,9 +56,12 @@ 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.1.3...HEAD
|
[Unreleased]: https://git.engelgardt23.ru/engel/dhcpsrv/compare/v1.2.3...HEAD
|
||||||
[1.1.3]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.2...v1.1.3
|
[1.2.3]: https://git.engelgardt23.ru/engel/dhcpsrv/compare/v1.2.1...v1.2.3
|
||||||
[1.1.2]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.1...v1.1.2
|
[1.2.1]: https://git.engelgardt23.ru/engel/dhcpsrv/compare/v1.2.0...v1.2.1
|
||||||
[1.1.1]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.1.0...v1.1.1
|
[1.2.0]: https://git.engelgardt23.ru/engel/dhcpsrv/compare/v1.1.3...v1.2.0
|
||||||
[1.1.0]: https://github.com/Engelgardt23/dhcpsrv/compare/v1.0.0...v1.1.0
|
[1.1.3]: https://git.engelgardt23.ru/engel/dhcpsrv/compare/v1.1.2...v1.1.3
|
||||||
[1.0.0]: https://github.com/Engelgardt23/dhcpsrv/releases/tag/v1.0.0
|
[1.1.2]: https://git.engelgardt23.ru/engel/dhcpsrv/compare/v1.1.1...v1.1.2
|
||||||
|
[1.1.1]: https://git.engelgardt23.ru/engel/dhcpsrv/compare/v1.1.0...v1.1.1
|
||||||
|
[1.1.0]: https://git.engelgardt23.ru/engel/dhcpsrv/compare/v1.0.0...v1.1.0
|
||||||
|
[1.0.0]: https://git.engelgardt23.ru/engel/dhcpsrv/releases/tag/v1.0.0
|
||||||
|
|||||||
+5
-6
@@ -6,15 +6,14 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
dhcpsrv/
|
dhcpsrv/
|
||||||
├── .github/
|
├── .gitea/
|
||||||
│ ├── workflows/release.yml ← CI: tag-driven build + GitHub Release
|
│ └── workflows/release.yml ← CI: tag-driven build + Gitea Release
|
||||||
│ └── ISSUE_TEMPLATE/ ← bug / feature / security routing
|
|
||||||
├── src/dhcpsrv/ ← package source (≤200 lines per module)
|
├── src/dhcpsrv/ ← package source (≤200 lines per module)
|
||||||
│ ├── __init__.py ← single source of truth for __version__
|
│ ├── __init__.py ← single source of truth for __version__
|
||||||
│ ├── __main__.py ← entry: python -m dhcpsrv
|
│ ├── __main__.py ← entry: python -m dhcpsrv
|
||||||
│ ├── app.py ← main flow, wires everything
|
│ ├── app.py ← main flow, wires everything
|
||||||
│ ├── platform_win.py ← VT enable + UAC self-elevate
|
│ ├── platform_win.py ← VT enable + UAC self-elevate
|
||||||
│ ├── update_check.py ← GitHub /releases/latest poll
|
│ ├── update_check.py ← Gitea /releases/latest poll
|
||||||
│ ├── network.py ← list_adapters, netsh, ping (no shared state)
|
│ ├── network.py ← list_adapters, netsh, ping (no shared state)
|
||||||
│ ├── dhcp.py ← DhcpConfig, DhcpServer, packet parse/build
|
│ ├── dhcp.py ← DhcpConfig, DhcpServer, packet parse/build
|
||||||
│ └── ui.py ← rich-based full-screen TUI
|
│ └── ui.py ← rich-based full-screen TUI
|
||||||
@@ -64,7 +63,7 @@ flag tells PyInstaller where to find the `dhcpsrv` package itself. Output:
|
|||||||
4. Tag: `git tag vX.Y.Z`.
|
4. Tag: `git tag vX.Y.Z`.
|
||||||
5. Push: `git push && git push --tags`.
|
5. Push: `git push && git push --tags`.
|
||||||
|
|
||||||
That's it. GitHub Actions picks up the tag, builds the exe, writes the SHA-256, and creates the GitHub Release with the zip attached.
|
That's it. Gitea Actions picks up the tag, builds the exe, writes the SHA-256, and creates the Gitea Release with the zip attached.
|
||||||
|
|
||||||
## Where features go
|
## Where features go
|
||||||
|
|
||||||
@@ -76,4 +75,4 @@ That's it. GitHub Actions picks up the tag, builds the exe, writes the SHA-256,
|
|||||||
| Something shown in the header | `ui.py` → `Ui._render_header` |
|
| Something shown in the header | `ui.py` → `Ui._render_header` |
|
||||||
| A startup check or banner line | `app.py` → `main()` |
|
| A startup check or banner line | `app.py` → `main()` |
|
||||||
| A change to UAC / VT logic | `platform_win.py` |
|
| A change to UAC / VT logic | `platform_win.py` |
|
||||||
| Tweaking the GitHub update-check UX | `update_check.py` |
|
| Tweaking the update-check UX | `update_check.py` |
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
# dhcpsrv
|
# dhcpsrv
|
||||||
|
|
||||||
[](https://github.com/Engelgardt23/dhcpsrv/releases/latest)
|
[](https://git.engelgardt23.ru/engel/dhcpsrv/releases/latest)
|
||||||
[](LICENSE)
|
[](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.
|
||||||
|
|
||||||
Built for the “plug the cable in, watch a BMC pop up with an IP” workflow during firmware updates, recovery, and benchmarks.
|
Built for the “plug the cable in, watch a BMC pop up with an IP” workflow during firmware updates, recovery, and benchmarks.
|
||||||
|
|
||||||
> **Made by engelgardt.**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Grab the latest release: [**releases page**](https://github.com/Engelgardt23/dhcpsrv/releases/latest).
|
Grab the latest release: [**releases page**](https://git.engelgardt23.ru/engel/dhcpsrv/releases/latest).
|
||||||
The asset is `dhcpsrv-portable-vX.Y.Z.zip` (~12 MB).
|
The asset is `dhcpsrv-portable-vX.Y.Z.zip` (~12 MB).
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
@@ -37,14 +37,14 @@ The asset is `dhcpsrv-portable-vX.Y.Z.zip` (~12 MB).
|
|||||||
## What's on screen
|
## What's on screen
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─ dhcpsrv v1.0.0 made by engelgardt ────────────────────────────────────┐
|
┌─ dhcpsrv v1.0.0 ────────────────────────────────────────────────────────┐
|
||||||
│ Server: 10.10.10.1/255.255.255.0 Pool: 10.10.10.2–10.10.10.51 … │
|
│ Server: 10.10.10.1/255.255.255.0 Pool: 10.10.10.2–10.10.10.51 … │
|
||||||
│ Leases: 3/50 Pkts: 47 DISCOVER: 12 REQUEST: 11 RELEASE: 0 │
|
│ Leases: 3/50 Pkts: 47 DISCOVER: 12 REQUEST: 11 RELEASE: 0 │
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
┌─ Clients ───────────────────────────────────────────────────────────────┐
|
┌─ Clients ───────────────────────────────────────────────────────────────┐
|
||||||
│ # │ IP │ Hostname │ MAC │ Last seen │ Ping │
|
│ # │ IP │ Hostname │ MAC │ Last seen │ Ping │
|
||||||
│ 1 │ 10.10.10.2 │ vegman-r120 │ a0:c5:f2:13:57:46 │ 17:42:18 │ OK │
|
│ 1 │ 10.10.10.2 │ server-01 │ a0:c5:f2:13:57:46 │ 17:42:18 │ OK │
|
||||||
│ 2 │ 10.10.10.3 │ vegman-s220 │ 70:b3:d5:11:22:33 │ 17:42:21 │ -- │
|
│ 2 │ 10.10.10.3 │ server-02 │ 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 │
|
||||||
@@ -54,7 +54,7 @@ The asset is `dhcpsrv-portable-vX.Y.Z.zip` (~12 MB).
|
|||||||
|
|
||||||
## Typical scenarios
|
## Typical scenarios
|
||||||
|
|
||||||
- **VEGMAN with shared LOM** — one cable into the BMC/host port, BMC and the host OS both get IPs from this DHCP.
|
- **Server 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).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# dhcpsrv
|
||||||
|
|
||||||
|
[](https://git.engelgardt23.ru/engel/dhcpsrv/releases/latest)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
[🇺🇸 English](README.md) | 🇷🇺 Русский
|
||||||
|
|
||||||
|
Маленький портативный **DHCP-сервер** для ноутбука инженера хранения / серверов.
|
||||||
|
Двойной клик — выбрал сетевую — готово. Живая таблица клиентов, статус ping, счётчики пакетов. Ничего не устанавливается, Python на целевой машине не нужен.
|
||||||
|
|
||||||
|
Сделан под сценарий «воткнул кабель, увидел как BMC получил IP» во время прошивки, восстановления и бенчмарков.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Скачать
|
||||||
|
|
||||||
|
Последний релиз: [**страница релизов**](https://git.engelgardt23.ru/engel/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.2–10.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).
|
||||||
+2
-4
@@ -6,16 +6,14 @@ on the host — so vulnerability reports are very welcome.
|
|||||||
|
|
||||||
## Supported versions
|
## Supported versions
|
||||||
|
|
||||||
Only the latest tagged release on GitHub is supported. Older versions will not
|
Only the latest tagged release is supported. Older versions will not
|
||||||
get fixes; please upgrade first.
|
get fixes; please upgrade first.
|
||||||
|
|
||||||
## How to report a vulnerability
|
## How to report a vulnerability
|
||||||
|
|
||||||
**Please do not open a public issue** for security-sensitive findings.
|
**Please do not open a public issue** for security-sensitive findings.
|
||||||
|
|
||||||
Use GitHub's private security advisories: go to the
|
Report privately by email to the maintainer at engelgardt2024@gmail.com.
|
||||||
[Security tab](../../security/advisories/new) of this repo and click
|
|
||||||
"Report a vulnerability". GitHub will route it privately.
|
|
||||||
|
|
||||||
Please include:
|
Please include:
|
||||||
- The version you tested (the startup banner is enough).
|
- The version you tested (the startup banner is enough).
|
||||||
|
|||||||
+2
-2
@@ -13,8 +13,8 @@ dependencies = ["rich>=13"]
|
|||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/Engelgardt23/dhcpsrv"
|
Homepage = "https://git.engelgardt23.ru/engel/dhcpsrv"
|
||||||
Issues = "https://github.com/Engelgardt23/dhcpsrv/issues"
|
Issues = "https://git.engelgardt23.ru/engel/dhcpsrv/issues"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
dhcpsrv = "dhcpsrv.app:main"
|
dhcpsrv = "dhcpsrv.app:main"
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
dhcpsrv - portable laptop-side DHCP server for storage/server engineers.
|
dhcpsrv - portable laptop-side DHCP server for storage/server engineers.
|
||||||
made by engelgardt
|
|
||||||
|
|
||||||
The single source of truth for the project version. Bump this before tagging
|
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.1.3"
|
__version__ = "1.2.4"
|
||||||
GITHUB_REPO = "Engelgardt23/dhcpsrv"
|
REPO = "engel/dhcpsrv" # self-hosted Gitea (git.engelgardt23.ru)
|
||||||
|
|||||||
+30
-15
@@ -11,54 +11,69 @@ from rich.console import Console
|
|||||||
from rich.prompt import Confirm, Prompt
|
from rich.prompt import Confirm, Prompt
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__, REPO
|
||||||
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, auto_single: bool = True) -> dict | None:
|
||||||
console.rule("[bold cyan]Available adapters")
|
console.rule(f"[bold cyan]{t('available_adapters')}")
|
||||||
adapters = list_adapters()
|
adapters = list_adapters()
|
||||||
if not adapters:
|
if not adapters:
|
||||||
console.print("[red]No suitable wired adapters found.[/]")
|
console.print(f"[red]{t('no_adapters')}[/]")
|
||||||
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}")
|
||||||
|
if auto_single and len(adapters) == 1:
|
||||||
|
only = adapters[0]
|
||||||
|
console.print(f"[green]{t('only_adapter', name=only['Name'])}[/]")
|
||||||
|
return only
|
||||||
while True:
|
while True:
|
||||||
s = Prompt.ask("Select adapter number").strip()
|
s = Prompt.ask(t("select_adapter")).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("[red]Invalid selection.[/]")
|
console.print(f"[red]{t('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)
|
||||||
|
|
||||||
title = f"[bold cyan]dhcpsrv v{__version__}[/] - portable laptop-side DHCP server"
|
title = f"[bold cyan]dhcpsrv v{__version__}[/] {t('tagline')}"
|
||||||
latest = check_for_update()
|
latest = check_for_update()
|
||||||
if latest:
|
if latest:
|
||||||
|
release_url = f"https://git.engelgardt23.ru/{REPO}/releases/latest"
|
||||||
|
notice = t("update_available", tag=latest)
|
||||||
header = Table.grid(expand=True)
|
header = Table.grid(expand=True)
|
||||||
header.add_column(justify="left", ratio=1)
|
header.add_column(justify="left", ratio=1)
|
||||||
header.add_column(justify="right")
|
header.add_column(justify="right")
|
||||||
header.add_row(title, f"[dim]update available ({latest})[/]")
|
header.add_row(title, f"[dim][link={release_url}]{notice}[/link][/]")
|
||||||
console.print(header)
|
console.print(header)
|
||||||
else:
|
else:
|
||||||
console.print(title)
|
console.print(title)
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
nic = _select_nic(console)
|
nic = _select_nic(console, cfg_data.get("auto_select_single", True))
|
||||||
if not nic:
|
if not nic:
|
||||||
input("Press Enter to exit"); return
|
input(t("press_enter")); return
|
||||||
|
|
||||||
cfg = DhcpConfig.with_defaults()
|
cfg = DhcpConfig.with_defaults()
|
||||||
console.print(f"[yellow]Setting {nic['Name']} → {cfg.server_ip} / {cfg.netmask} ...[/]")
|
console.print(f"[yellow]{t('setting_nic', name=nic['Name'], ip=cfg.server_ip, mask=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.
|
||||||
@@ -74,15 +89,15 @@ def main() -> None:
|
|||||||
stop.set()
|
stop.set()
|
||||||
try:
|
try:
|
||||||
print()
|
print()
|
||||||
print(f"[{now_s()}] Shutting down...")
|
print(t("shutting_down", ts=now_s()))
|
||||||
try:
|
try:
|
||||||
if Confirm.ask(f"Revert {nic['Name']} back to DHCP?", default=False):
|
if Confirm.ask(t("revert_nic", name=nic["Name"]), default=False):
|
||||||
revert_to_dhcp(nic["Name"])
|
revert_to_dhcp(nic["Name"])
|
||||||
print("NIC reverted to DHCP")
|
print(t("nic_reverted"))
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
input("Press Enter to exit")
|
input(t("press_enter"))
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, shutdown)
|
signal.signal(signal.SIGINT, shutdown)
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
#
|
||||||
|
# language : interface language. Valid: en, ru
|
||||||
|
# auto_select_single : auto-pick the network adapter when only one is found,
|
||||||
|
# without asking (yes/no).
|
||||||
|
#
|
||||||
|
# language : язык интерфейса. Допустимо: en, ru
|
||||||
|
# auto_select_single : автоматически выбирать сетевой адаптер, если он
|
||||||
|
# единственный, не спрашивая (yes/no).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _to_bool(s: str, default: bool) -> bool:
|
||||||
|
s = (s or "").strip().lower()
|
||||||
|
if s in ("yes", "true", "y", "1", "on"): return True
|
||||||
|
if s in ("no", "false", "n", "0", "off"): return False
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
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 _ask_auto_select() -> bool:
|
||||||
|
"""First-run prompt — auto-select a lone adapter? Bilingual stdin."""
|
||||||
|
print()
|
||||||
|
print("Auto-select the network adapter when only one is found? (y/n)")
|
||||||
|
print("Автовыбор сетевого адаптера, если он единственный? (y/n)")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
c = input("> ").strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
return True
|
||||||
|
if c in ("y", "yes", "д", "да", "1"): return True
|
||||||
|
if c in ("n", "no", "н", "нет", "0"): return False
|
||||||
|
print("y / n")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_config(lang: str, auto_select: bool) -> None:
|
||||||
|
path = config_path()
|
||||||
|
cp = configparser.ConfigParser()
|
||||||
|
cp["General"] = {"language": lang,
|
||||||
|
"auto_select_single": "yes" if auto_select else "no"}
|
||||||
|
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()
|
||||||
|
auto = _ask_auto_select()
|
||||||
|
try:
|
||||||
|
_write_config(lang, auto)
|
||||||
|
except OSError:
|
||||||
|
# read-only location — fall back to in-memory default
|
||||||
|
pass
|
||||||
|
return {"language": lang, "auto_select_single": auto}
|
||||||
|
|
||||||
|
cp = configparser.ConfigParser()
|
||||||
|
try:
|
||||||
|
cp.read(path, encoding="utf-8")
|
||||||
|
except (configparser.Error, OSError):
|
||||||
|
return {"language": DEFAULT_LANG, "auto_select_single": True}
|
||||||
|
|
||||||
|
lang = (cp.get("General", "language", fallback=DEFAULT_LANG) or DEFAULT_LANG).strip().lower()
|
||||||
|
if lang not in SUPPORTED_LANGS:
|
||||||
|
lang = DEFAULT_LANG
|
||||||
|
auto = _to_bool(cp.get("General", "auto_select_single", fallback="yes"), True)
|
||||||
|
return {"language": lang, "auto_select_single": auto}
|
||||||
+24
-3
@@ -15,6 +15,8 @@ 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
|
||||||
|
from .leases import write_leases
|
||||||
|
|
||||||
|
|
||||||
# ---------- helpers ----------
|
# ---------- helpers ----------
|
||||||
@@ -146,6 +148,23 @@ class DhcpServer:
|
|||||||
o += bytes([255])
|
o += bytes([255])
|
||||||
return bytes(pkt) + bytes(o)
|
return bytes(pkt) + bytes(o)
|
||||||
|
|
||||||
|
# --- lease-table export (contract for sibling tools, e.g. vrcx) ---
|
||||||
|
def export_leases(self) -> None:
|
||||||
|
"""Snapshot the lease table to the shared JSON file. Best-effort."""
|
||||||
|
with self.lock:
|
||||||
|
leases = [
|
||||||
|
{
|
||||||
|
"ip": int2ip(c["ip_int"]),
|
||||||
|
"mac": mac,
|
||||||
|
"host": c.get("host", ""),
|
||||||
|
"ping_ok": bool(c.get("ping_ok")),
|
||||||
|
"last": c.get("last", ""),
|
||||||
|
}
|
||||||
|
for mac, c in self.clients.items()
|
||||||
|
if c.get("ip_int")
|
||||||
|
]
|
||||||
|
write_leases(self.cfg.server_ip, leases, now_s())
|
||||||
|
|
||||||
# --- ping loop, runs in its own thread ---
|
# --- ping loop, runs in its own thread ---
|
||||||
def ping_loop(self, stop: threading.Event) -> None:
|
def ping_loop(self, stop: threading.Event) -> None:
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
@@ -165,6 +184,8 @@ class DhcpServer:
|
|||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
self.on_change()
|
self.on_change()
|
||||||
|
# Refresh the export every tick so its mtime reflects liveness.
|
||||||
|
self.export_leases()
|
||||||
|
|
||||||
# --- main server loop ---
|
# --- main server loop ---
|
||||||
def run(self, stop: threading.Event) -> None:
|
def run(self, stop: threading.Event) -> None:
|
||||||
@@ -174,8 +195,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]bind UDP/67 failed:[/] {e}")
|
self.log(f"[bold red]{_t('bind_failed')}[/] {e}")
|
||||||
self.log("[yellow]Another DHCP service (Tftpd32 DHCP, ICS, Windows DHCP) may be running.[/]")
|
self.log(f"[yellow]{_t('bind_hint')}[/]")
|
||||||
stop.set()
|
stop.set()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -200,7 +221,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]POOL EXHAUSTED[/]")
|
self.log(f"[dim][{now_s()}][/] [red]DISCOVER[/] {mac} → [red]{_t('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))
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
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.",
|
||||||
|
"only_adapter": "Only one adapter, auto-selected: {name}",
|
||||||
|
"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": "Подходящие проводные адаптеры не найдены.",
|
||||||
|
"only_adapter": "Адаптер один, выбран автоматически: {name}",
|
||||||
|
"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
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
Lease-table export — the shared contract with sibling tools (e.g. vrcx).
|
||||||
|
|
||||||
|
dhcpsrv owns the DHCP state; other tools must NOT reach into it. Instead we
|
||||||
|
periodically dump a small, self-describing JSON snapshot to a fixed,
|
||||||
|
tool-neutral location. Consumers read that file (and judge its freshness by
|
||||||
|
the file mtime). No shared code, no network call between the tools.
|
||||||
|
|
||||||
|
Default location: %PROGRAMDATA%\\vegman\\leases.json (override via the
|
||||||
|
VEGMAN_LEASES env var). The directory is created on first write.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCHEMA = 1
|
||||||
|
|
||||||
|
|
||||||
|
def leases_path() -> Path:
|
||||||
|
override = os.environ.get("VEGMAN_LEASES")
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
base = os.environ.get("PROGRAMDATA") or os.environ.get("LOCALAPPDATA") or "."
|
||||||
|
return Path(base) / "vegman" / "leases.json"
|
||||||
|
|
||||||
|
|
||||||
|
def write_leases(server_ip: str, leases: list[dict], updated: str) -> None:
|
||||||
|
"""Atomically write the snapshot. Best-effort: swallow I/O errors so a
|
||||||
|
read-only/locked target can never disturb the DHCP service."""
|
||||||
|
path = leases_path()
|
||||||
|
payload = {
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"server_ip": server_ip,
|
||||||
|
"updated": updated,
|
||||||
|
"leases": leases,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".leases-", suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(payload, f, ensure_ascii=False)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
+22
-21
@@ -19,6 +19,7 @@ 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.
|
||||||
@@ -54,27 +55,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"Server: [bold]{cfg.server_ip}[/]/{cfg.netmask} "
|
f"{_t('panel_server')}: [bold]{cfg.server_ip}[/]/{cfg.netmask} "
|
||||||
f"Pool: [bold]{int2ip(cfg.pool[0])}–{int2ip(cfg.pool[-1])}[/] "
|
f"{_t('panel_pool')}: [bold]{int2ip(cfg.pool[0])}–{int2ip(cfg.pool[-1])}[/] "
|
||||||
f"Lease: [bold]{cfg.lease}s[/] "
|
f"{_t('panel_lease')}: [bold]{cfg.lease}s[/] "
|
||||||
f"TFTP: [bold]{cfg.tftp}[/]\n"
|
f"{_t('panel_tftp')}: [bold]{cfg.tftp}[/]\n"
|
||||||
f"Leases: [bold]{leased}/{len(cfg.pool)}[/] "
|
f"{_t('panel_leases')}: [bold]{leased}/{len(cfg.pool)}[/] "
|
||||||
f"Pkts: [dim]{st['packets']}[/] "
|
f"{_t('panel_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]Ctrl+C to stop[/]"
|
f"[dim]{_t('panel_ctrlc')}[/]"
|
||||||
)
|
)
|
||||||
return Panel(body, border_style="cyan")
|
return Panel(body, border_style="cyan")
|
||||||
|
|
||||||
def _render_table(self) -> Table:
|
def _render_table(self) -> Table:
|
||||||
t = Table(expand=True, header_style="bold")
|
tbl = Table(expand=True, header_style="bold")
|
||||||
t.add_column("#", style="dim", width=3, justify="right")
|
tbl.add_column("#", style="dim", width=3, justify="right")
|
||||||
t.add_column("IP", width=16)
|
tbl.add_column(_t("col_ip"), width=16)
|
||||||
t.add_column("Hostname", min_width=10)
|
tbl.add_column(_t("col_host"), min_width=10)
|
||||||
t.add_column("MAC", width=19)
|
tbl.add_column(_t("col_mac"), width=19)
|
||||||
t.add_column("Last seen", style="dim", width=10)
|
tbl.add_column(_t("col_last"), style="dim", width=10)
|
||||||
t.add_column("Ping", width=6, justify="center")
|
tbl.add_column(_t("col_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"])
|
||||||
@@ -85,12 +86,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:
|
||||||
t.add_row("—", "—", "(no clients yet)", "—", "—", "—")
|
tbl.add_row("—", "—", _t("no_clients"), "—", "—", "—")
|
||||||
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"))
|
||||||
t.add_row(
|
tbl.add_row(
|
||||||
str(i),
|
str(i),
|
||||||
int2ip(c["ip_int"]),
|
int2ip(c["ip_int"]),
|
||||||
c.get("host") or "—",
|
c.get("host") or "—",
|
||||||
@@ -99,20 +100,20 @@ class Ui:
|
|||||||
ping,
|
ping,
|
||||||
)
|
)
|
||||||
if overflow:
|
if overflow:
|
||||||
t.add_row("…", "", f"[dim](+{overflow} more — enlarge the window)[/]", "", "", "")
|
tbl.add_row("…", "", f"[dim]{_t('more_clients', n=overflow)}[/]", "", "", "")
|
||||||
return t
|
return tbl
|
||||||
|
|
||||||
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 "[dim](no events yet)[/]"
|
body = "\n".join(last) if last else f"[dim]{_t('no_events')}[/]"
|
||||||
return Panel(body, title="Events", border_style="dim")
|
return Panel(body, title=_t("events_title"), 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="Clients", border_style="cyan"), name="tbl"),
|
Layout(Panel(self._render_table(), title=_t("clients_title"), 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
|
||||||
|
|||||||
@@ -1,16 +1,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 the Gitea instance for the latest release tag. If it's newer
|
||||||
local `__version__`, return the tag string so the caller can show a quiet
|
than the local `__version__`, return the tag string so the caller can show a
|
||||||
hint in the header. Silent on any error (offline, rate-limit, etc.).
|
quiet hint in the header. 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
|
||||||
|
|
||||||
from . import __version__, GITHUB_REPO
|
from . import __version__, REPO
|
||||||
|
|
||||||
|
|
||||||
def _parse_version(s: str) -> tuple[int, int, int]:
|
def _parse_version(s: str) -> tuple[int, int, int]:
|
||||||
@@ -29,9 +29,8 @@ def check_for_update() -> str | None:
|
|||||||
currently running version. Returns None when up to date, offline, or on
|
currently running version. Returns None when up to date, offline, or on
|
||||||
any error — the caller decides how (or whether) to render the hint."""
|
any error — the caller decides how (or whether) to render the hint."""
|
||||||
try:
|
try:
|
||||||
url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
url = f"https://git.engelgardt23.ru/api/v1/repos/{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:
|
||||||
|
|||||||
Reference in New Issue
Block a user