Compare commits

..

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

23 changed files with 150 additions and 994 deletions

View file

@ -1,55 +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.0.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: adapter
attributes:
label: Adapter (if relevant)
placeholder: e.g. Realtek USB GbE Family Controller
- type: textarea
id: extra
attributes:
label: Anything else?

View file

@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/Engelgardt23/netswitch/security/advisories/new
about: Please report security issues privately via GitHub Security Advisories — not as a public issue.

View file

@ -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

View file

@ -1,75 +0,0 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
permissions:
contents: write
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install build dependencies
run: python -m pip install --upgrade pip pyinstaller rich
- name: Resolve version from tag
id: ver
shell: bash
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build executable
run: python -m PyInstaller --onefile --uac-admin --console --name netswitch --icon assets/icon.ico --paths src netswitch-launcher.py
- name: Package portable folder
shell: pwsh
run: |
$ver = '${{ steps.ver.outputs.version }}'
$folder = "netswitch-v$ver"
New-Item -ItemType Directory -Path $folder | Out-Null
Copy-Item dist/netswitch.exe $folder/
@"
netswitch v$ver - portable edition
made by engelgardt
Quickly set a Windows network adapter to a static IP or back to DHCP.
USAGE
Double-click netswitch.exe.
Pick the language on first run (1 - English, 2 - Russian).
Accept the UAC prompt.
Pick the NIC, then choose mode (Static / DHCP).
Press Enter to exit.
NOTES
- Nothing is installed. Delete the folder to remove.
- Requires Windows 10/11.
- Language can be changed any time by editing 'language = en/ru' in config.ini.
"@ | Out-File -FilePath "$folder/README.txt" -Encoding UTF8
Compress-Archive -Path $folder -DestinationPath "netswitch-portable-v$ver.zip"
- name: Generate SHA-256 checksum
shell: pwsh
run: |
$ver = '${{ steps.ver.outputs.version }}'
$zip = "netswitch-portable-v$ver.zip"
$hash = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower()
"$hash $zip" | Out-File -FilePath "$zip.sha256" -Encoding ASCII -NoNewline
Get-Content "$zip.sha256"
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: |
netswitch-portable-v${{ steps.ver.outputs.version }}.zip
netswitch-portable-v${{ steps.ver.outputs.version }}.zip.sha256
generate_release_notes: true

20
.gitignore vendored
View file

@ -1,22 +1,8 @@
# PyInstaller build artifacts
build/
dist/
*.spec
# Python cache
__pycache__/
*.py[cod]
# Local build cache (prod/test/old portable folders per version)
builds/
# Legacy staging folders (kept for compatibility with old checkouts)
# Build output / staging
*.exe
portable-v*/
# Stray standalone exe (we never commit binaries — they live in GitHub releases)
*.exe
# Local backup of release archives (kept locally for history, not in repo)
# Local backup of release archives
releases/
# Editor / OS junk

View file

@ -1,53 +0,0 @@
# Changelog
All notable changes to **netswitch** are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.2.0] - 2026-05-18
### Changed
- **Rewrote netswitch in Python** (was PowerShell + ps2exe). The single-file `.ps1` script is gone, replaced by a small `netswitch/` package (`app.py`, `config.py`, `i18n.py`, `network.py`, `platform_win.py`, `update_check.py`) that mirrors the layout used by `dhcpsrv`. CI now builds via PyInstaller instead of ps2exe.
- Output is now a Rich-styled console (coloured banner, current-config table) instead of plain `Write-Host`.
- The `Update available (vX.Y.Z)` notice in the header is a clickable terminal hyperlink to the GitHub releases page (OSC 8). Modern terminals render it as a link; older consoles show plain text.
### Removed
- `src/netswitch.ps1` and the ps2exe build step. If you specifically need a tiny PowerShell version, check out tag `v1.1.0`.
## [1.1.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 `netswitch.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.
- Bilingual `README.ru.md` linked from the main `README.md`.
## [1.0.3] - 2026-05-17
### Changed
- Update check no longer interrupts startup with an interactive prompt. If a newer release is available, a quiet right-aligned `update available (vX.Y.Z)` hint is printed in dim grey directly under the banner — no key press required.
## [1.0.2] - 2026-05-17
### Fixed
- Suppress `netsh` output to avoid mojibake on non-UTF-8 consoles (Russian Windows printed `╤Г╨╢╨╡ ╨▓╨║╨╗╤О╤З╨╡╨╜╨╛...` because netsh emits in the OEM code page). Our own English status lines remain; the post-change `Get-NetIPAddress` block already prints Unicode.
## [1.0.1] - 2026-05-17
### Changed
- Dropped the `made by engelgardt` line from the startup banner — keep author credit in the README only.
### Added
- Embedded application icon in the exe (via ps2exe `-iconFile assets/icon.ico`).
## [1.0.0] - 2026-05-16
### Added
- First public release on GitHub.
- Portable `.exe` (~30 KB) built from PowerShell via ps2exe.
- Self-elevation through UAC.
- Physical wired NIC filter — Wi-Fi, VPN, virtual, Hyper-V, VMware, VirtualBox, TAP/TUN, WireGuard, OpenVPN, Tailscale, ZeroTier, Bluetooth, Loopback and WAN Miniport adapters are hidden from the picker.
- Two modes: **Static** (default `10.10.10.1/24`, optional gateway) and **DHCP**.
- Current IPv4 configuration is printed after the change.
- Auto-update check on startup: polls GitHub `/releases/latest` with a 3-second timeout and offers to open the download page if a newer version exists. Silent on offline / API errors.
- MIT licensed.
[Unreleased]: https://github.com/Engelgardt23/netswitch/compare/v1.2.0...HEAD
[1.2.0]: https://github.com/Engelgardt23/netswitch/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/Engelgardt23/netswitch/compare/v1.0.3...v1.1.0
[1.0.3]: https://github.com/Engelgardt23/netswitch/compare/v1.0.2...v1.0.3
[1.0.2]: https://github.com/Engelgardt23/netswitch/compare/v1.0.1...v1.0.2
[1.0.1]: https://github.com/Engelgardt23/netswitch/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/Engelgardt23/netswitch/releases/tag/v1.0.0

View file

@ -1,62 +0,0 @@
# Contributing
> Project layout, build, and release flow. **If you only want to use the tool — read [README](README.md) instead.**
## Repo layout
```
netswitch/
├── .github/
│ ├── workflows/release.yml ← CI: tag-driven build + GitHub Release
│ └── ISSUE_TEMPLATE/ ← bug / feature / security routing
├── src/
│ └── netswitch.ps1 ← the whole tool (~150 lines)
├── CHANGELOG.md ← Keep a Changelog format, newest first
├── CONTRIBUTING.md ← this file
├── LICENSE / README.md / SECURITY.md
└── .gitignore
```
## Run from source (no exe)
```
powershell -ExecutionPolicy Bypass -File src/netswitch.ps1
```
The script self-elevates through UAC if you launched it without admin.
## Build the portable .exe locally
```
Install-Module ps2exe -Scope CurrentUser
Invoke-ps2exe -inputFile src/netswitch.ps1 -outputFile netswitch.exe `
-title "netswitch" -description "NIC IP/DHCP toggle - made by engelgardt" `
-company "engelgardt" -version "1.0.0.0" `
-requireAdmin
```
## Cut a release
1. Update `src/netswitch.ps1` — bump `$NetswitchVersion` to `X.Y.Z`.
2. Update `CHANGELOG.md` — move items from `[Unreleased]` into a new `[X.Y.Z]` section with today's date.
3. Commit: `git commit -am "vX.Y.Z: …"`.
4. Tag: `git tag vX.Y.Z`.
5. Push: `git push && git push --tags`.
GitHub Actions picks up the tag, builds the exe via ps2exe, writes the SHA-256, and creates the GitHub Release with the zip attached.
## Where features go
The whole tool is a single PowerShell file with these sections (in order):
1. `$NetswitchVersion` / `$GithubRepo` — top of file.
2. **Self-elevate** block — UAC if non-admin.
3. **Banner** — Write-Host title.
4. **`Test-NetswitchUpdate`** — GitHub /releases/latest poll.
5. **Adapter filter + picker**`$skipDescriptionPattern`, `$skipMediaTypes`, `Get-NetAdapter | Where-Object {...}`.
6. **Mode prompt** — Static / DHCP.
7. **Static branch**`netsh interface ipv4 set address ... static ...`.
8. **DHCP branch**`netsh interface ipv4 set address ... source=dhcp`.
9. **Current config display**`Get-NetIPAddress | Format-Table`.
If a section grows past ~50 lines, factor it into `src/lib/<thing>.ps1` and dot-source it from the main file.

View file

@ -3,8 +3,6 @@
[![Latest release](https://img.shields.io/github/v/release/Engelgardt23/netswitch)](https://github.com/Engelgardt23/netswitch/releases/latest)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
🇺🇸 English | [🇷🇺 Русский](README.ru.md)
A tiny portable tool to flip a Windows network adapter between a **static IP** and **DHCP** with a few keystrokes.
Built for the recurring engineer chore of "give my laptop NIC 10.10.10.1 so I can talk to a server's BMC" and "now put it back on DHCP so I can have internet again."
@ -38,9 +36,11 @@ On every launch the tool calls GitHub's `/releases/latest` (3-second timeout). I
## Build from source
The script is a single `netswitch.ps1`. To rebuild the bundled `.exe`:
```
python -m pip install rich pyinstaller
python -m PyInstaller --onefile --uac-admin --console --name netswitch --icon assets/icon.ico --paths src netswitch-launcher.py
Install-Module ps2exe -Scope CurrentUser
Invoke-ps2exe -inputFile netswitch.ps1 -outputFile netswitch.exe -requireAdmin -title "netswitch" -version 1.0.0.0
```
## License

View file

@ -1,60 +0,0 @@
# netswitch
[![Последний релиз](https://img.shields.io/github/v/release/Engelgardt23/netswitch)](https://github.com/Engelgardt23/netswitch/releases/latest)
[![Лицензия: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[🇺🇸 English](README.md) | 🇷🇺 Русский
Маленький портативный инструмент, который за пару нажатий переключает сетевой адаптер Windows между **статическим IP** и **DHCP**.
Решает регулярную задачу инженера: «дай моему ноуту 10.10.10.1, чтобы я мог достучаться до BMC сервера», а потом «верни обратно на DHCP, чтобы был интернет».
> **Автор: engelgardt.**
---
## Скачать
Последний релиз: [**страница релизов**](https://github.com/Engelgardt23/netswitch/releases/latest).
Архив `netswitch-portable-vX.Y.Z.zip` (~30 КБ).
## Запуск
1. Распакуй куда угодно.
2. Двойной клик по `netswitch.exe`.
3. **При первом запуске** программа спросит язык интерфейса (1 — English, 2 — Русский). Ответ запишется в `config.ini` рядом с exe — потом можно поменять руками.
4. Подтверди UAC (admin нужен для `netsh interface ipv4 set address`).
5. Выбери сетевой адаптер из списка.
6. Выбери режим:
- **Статический**: введи IP (по умолчанию `10.10.10.1`), маску (по умолчанию `255.255.255.0`), шлюз (опционально).
- **DHCP**: подтверди — адаптер вернётся в DHCP для IP и DNS.
## Что фильтруется
В выбор попадают только настоящие проводные физические адаптеры. Wi-Fi, VPN, виртуалки, Hyper-V, VMware, VirtualBox, TAP/TUN, WireGuard, OpenVPN, Tailscale, ZeroTier, Bluetooth, Loopback, WAN Miniport — всё пропускается.
## Проверка обновлений
При каждом запуске тулза стучится в GitHub `/releases/latest` (таймаут 3 секунды). Если есть свежая версия — справа в шапке появится тусклая надпись `доступно обновление (vX.Y.Z)`. Если интернета нет — молчит.
## Конфиг
При первом запуске рядом с `netswitch.exe` появится `config.ini`:
```ini
# Чтобы сменить язык интерфейса, измените 'language' ниже.
# Допустимые значения: en, ru
[General]
language = ru
```
## Сборка из исходников
```
python -m pip install rich pyinstaller
python -m PyInstaller --onefile --uac-admin --console --name netswitch --icon assets/icon.ico --paths src netswitch-launcher.py
```
## Лицензия
MIT — см. [LICENSE](LICENSE).

View file

@ -1,33 +0,0 @@
# Security policy
Thanks for taking the time to look at this. Even small tools can introduce real
risk — this one reconfigures network adapters from an elevated process — so
vulnerability reports are very welcome.
## Supported versions
Only the latest tagged release on GitHub is supported. Older versions will not
get fixes; please upgrade first.
## How to report a vulnerability
**Please do not open a public issue** for security-sensitive findings.
Use GitHub's private security advisories: go to the
[Security tab](../../security/advisories/new) of this repo and click
"Report a vulnerability". GitHub will route it privately.
Please include:
- The version you tested (the startup banner is enough).
- Steps to reproduce.
- An assessment of impact.
Reports are reviewed and addressed on a best-effort basis. A fix and a public
advisory will be published once the issue is resolved. Reporters are credited
unless they prefer to stay anonymous.
## Out of scope
- Behavior when run **without** administrator privileges (the tool refuses to
start in that case anyway).
- Issues that require the attacker to already control the user's machine.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View file

@ -1,12 +0,0 @@
"""
PyInstaller entry point sits at the repo root and uses an *absolute* import
so the bundled exe doesn't need relative-import resolution at runtime.
For dev work without an install use `python -m netswitch` instead.
"""
from netswitch.app import main
if __name__ == "__main__":
main()

143
netswitch.ps1 Normal file
View file

@ -0,0 +1,143 @@
# netswitch v1.0.0 - quick NIC IP / DHCP toggle
# made by engelgardt
$NetswitchVersion = '1.0.0'
$GithubRepo = 'Engelgardt23/netswitch'
$ErrorActionPreference = 'Stop'
# --- Self-elevate if not admin (no-op when launched from the ps2exe build which already requests admin) ---
$me = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $me.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
if ($PSCommandPath) {
Start-Process -FilePath 'powershell.exe' `
-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',"`"$PSCommandPath`"") `
-Verb RunAs
} else {
Start-Process -FilePath ([System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName) -Verb RunAs
}
exit
}
# --- Banner ---
Write-Host ""
Write-Host "==============================================" -ForegroundColor Cyan
Write-Host " netswitch v$NetswitchVersion - NIC IP/DHCP toggle" -ForegroundColor Cyan
Write-Host " made by engelgardt" -ForegroundColor DarkCyan
Write-Host "==============================================" -ForegroundColor Cyan
Write-Host ""
# --- Update check ---
function Test-NetswitchUpdate {
try {
$url = "https://api.github.com/repos/$GithubRepo/releases/latest"
$r = Invoke-RestMethod -Uri $url -TimeoutSec 3 -Headers @{ 'User-Agent' = "netswitch/$NetswitchVersion" }
$latest = ($r.tag_name -as [string]) -replace '^v',''
if (-not $latest) { return }
$toTuple = { param($s)
$parts = ($s -split '\.') | ForEach-Object {
$n = 0; [void][int]::TryParse($_, [ref]$n); $n
}
while ($parts.Count -lt 3) { $parts += 0 }
,$parts[0..2]
}
$L = & $toTuple $latest
$C = & $toTuple $NetswitchVersion
$isNewer = $false
for ($i = 0; $i -lt 3; $i++) {
if ($L[$i] -gt $C[$i]) { $isNewer = $true; break }
if ($L[$i] -lt $C[$i]) { break }
}
if ($isNewer) {
Write-Host "Update available: v$NetswitchVersion -> $($r.tag_name)" -ForegroundColor Yellow
$ans = Read-Host "Open the download page in your browser? [Y/n]"
if ($ans -notmatch '^(n|N|no|NO)$') {
Start-Process $r.html_url
}
Write-Host ""
}
} catch {
# silent on offline / API errors
}
}
Test-NetswitchUpdate
# --- Pick adapter (physical wired only) ---
$skipDescriptionPattern = 'VPN|Virtual|AnyConnect|TAP-|TUN-|Bluetooth|Loopback|WAN Miniport|Hyper-V|VMware|VirtualBox|WireGuard|OpenVPN|Tailscale|ZeroTier'
$skipMediaTypes = @('Native 802.11', 'Wireless WAN')
$adapters = @(Get-NetAdapter | Where-Object {
$_.Status -notin @('Disabled','Not Present') -and
-not $_.Virtual -and
$_.MediaType -notin $skipMediaTypes -and
$_.InterfaceDescription -notmatch $skipDescriptionPattern -and
$_.Name -notmatch $skipDescriptionPattern
} | Sort-Object ifIndex)
if ($adapters.Count -eq 0) {
Write-Host "No physical wired adapters found." -ForegroundColor Red
Read-Host "Press Enter to exit"; exit 1
}
Write-Host "Available adapters:"
for ($i = 0; $i -lt $adapters.Count; $i++) {
$a = $adapters[$i]
$ips = (Get-NetIPAddress -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.PrefixOrigin -ne 'WellKnown' }).IPAddress -join ', '
Write-Host (" {0}) [{1,-12}] {2} ({3}) {4}" -f ($i + 1), $a.Status, $a.Name, $a.InterfaceDescription, $ips)
}
do {
$sel = (Read-Host "Select adapter number").Trim()
$valid = ($sel -match '^\d+$') -and ([int]$sel -ge 1) -and ([int]$sel -le $adapters.Count)
if (-not $valid) { Write-Host "Invalid selection." -ForegroundColor Red }
} while (-not $valid)
$nic = $adapters[[int]$sel - 1]
Write-Host ""
Write-Host "Selected: $($nic.Name)" -ForegroundColor Green
# --- Mode ---
Write-Host ""
Write-Host "Mode:"
Write-Host " 1) Static IP"
Write-Host " 2) DHCP"
$modeChoice = Read-Host "Choice [1]"
if ([string]::IsNullOrWhiteSpace($modeChoice)) { $modeChoice = '1' }
if ($modeChoice.Trim() -eq '2') {
# --- DHCP ---
Write-Host ""
Write-Host "Setting $($nic.Name) to DHCP..." -ForegroundColor Yellow
& netsh interface ipv4 set address name="$($nic.Name)" source=dhcp
& netsh interface ipv4 set dnsservers name="$($nic.Name)" source=dhcp
Write-Host "Done." -ForegroundColor Green
}
else {
# --- Static ---
$ip = Read-Host "IP address [10.10.10.1]"
if ([string]::IsNullOrWhiteSpace($ip)) { $ip = '10.10.10.1' }
$mask = Read-Host "Subnet mask [255.255.255.0]"
if ([string]::IsNullOrWhiteSpace($mask)) { $mask = '255.255.255.0' }
$gw = Read-Host "Gateway (Enter to skip)"
Write-Host ""
Write-Host "Setting $($nic.Name) -> $ip / $mask$( if ($gw) { " via $gw" })" -ForegroundColor Yellow
if ([string]::IsNullOrWhiteSpace($gw)) {
& netsh interface ipv4 set address name="$($nic.Name)" static $ip $mask
} else {
& netsh interface ipv4 set address name="$($nic.Name)" static $ip $mask $gw
}
Write-Host "Done." -ForegroundColor Green
}
# --- Show current state ---
Write-Host ""
Write-Host "Current IPv4 config:" -ForegroundColor Cyan
Get-NetIPAddress -InterfaceIndex $nic.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.PrefixOrigin -ne 'WellKnown' } |
Format-Table IPAddress, PrefixLength, PrefixOrigin -AutoSize
Read-Host "Press Enter to exit"

View file

@ -1,26 +0,0 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "netswitch"
description = "Portable Windows NIC IP / DHCP toggle."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "engelgardt" }]
dependencies = ["rich>=13"]
dynamic = ["version"]
[project.urls]
Homepage = "https://github.com/Engelgardt23/netswitch"
Issues = "https://github.com/Engelgardt23/netswitch/issues"
[project.scripts]
netswitch = "netswitch.app:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.dynamic]
version = { attr = "netswitch.__version__" }

View file

@ -1,10 +0,0 @@
"""
netswitch - portable Windows NIC IP / DHCP toggle.
made by engelgardt
The single source of truth for the project version. Bump this before tagging
a release; CI reads the tag, the code reads this constant.
"""
__version__ = "1.2.1"
GITHUB_REPO = "engel/netswitch" # на Forgejo (git.engelgardt23.ru)

View file

@ -1,11 +0,0 @@
"""Entry point for `python -m netswitch` from a checked-out / installed package.
The PyInstaller-bundled exe uses `netswitch-launcher.py` at the repo root instead,
because PyInstaller runs the bundled script as a standalone module relative
imports fail there."""
from .app import main
if __name__ == "__main__":
main()

View file

@ -1,123 +0,0 @@
"""
netswitch entry: language prompt + admin elevation + banner + adapter picker +
mode (static/DHCP) + apply via netsh + show resulting config.
"""
from __future__ import annotations
import sys
from rich.console import Console
from rich.prompt import Prompt
from rich.table import Table
from . import __version__, GITHUB_REPO
from .platform_win import enable_vt, require_admin
from .config import load_config
from .i18n import set_language, t
from .update_check import check_for_update
from .network import list_adapters, set_static_ip, revert_to_dhcp, get_current_ipv4
def _pick_adapter(console: Console) -> dict | None:
adapters = list_adapters()
if not adapters:
console.print(f"[red]{t('no_adapters')}[/]")
return None
console.print(t("available_adapters"))
for i, a in enumerate(adapters, 1):
ip = a.get("IPv4") or ""
console.print(f" {i}) [{a['Status']:<12}] {a['Name']} ({a['Description']}) {ip}")
while True:
s = Prompt.ask(t("select_adapter")).strip()
if s.isdigit() and 1 <= int(s) <= len(adapters):
return adapters[int(s) - 1]
console.print(f"[red]{t('invalid_selection')}[/]")
def _show_current(console: Console, nic: dict) -> None:
console.print()
console.print(f"[cyan]{t('current_config')}[/]")
rows = get_current_ipv4(int(nic["ifIndex"]))
if not rows:
console.print(f"[dim]{t('no_ip')}[/]")
return
tbl = Table(show_edge=False, pad_edge=False)
tbl.add_column(t("col_ip"))
tbl.add_column(t("col_prefix"), justify="right")
tbl.add_column(t("col_origin"))
for r in rows:
tbl.add_row(str(r.get("ip") or ""),
str(r.get("prefix_len") or ""),
str(r.get("prefix_origin") or ""))
console.print(tbl)
def main() -> None:
enable_vt()
# Language prompt before admin elevation so the user doesn't have to answer
# it twice across the UAC bounce.
cfg_data = load_config()
set_language(cfg_data["language"])
require_admin()
console = Console(log_path=False)
title = f"[bold cyan]netswitch 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()
nic = _pick_adapter(console)
if not nic:
input(t("press_enter")); return
console.print()
console.print(f"[green]{t('selected', name=nic['Name'])}[/]")
console.print()
console.print(t("mode_header"))
console.print(t("mode_static"))
console.print(t("mode_dhcp"))
try:
choice = Prompt.ask(t("mode_choice"), default="1").strip() or "1"
except (EOFError, KeyboardInterrupt):
return
if choice == "2":
console.print()
console.print(f"[yellow]{t('setting_dhcp', name=nic['Name'])}[/]")
revert_to_dhcp(nic["Name"])
console.print(f"[green]{t('done')}[/]")
else:
ip = (Prompt.ask(t("ip_prompt"), default="10.10.10.1").strip() or "10.10.10.1")
mask = (Prompt.ask(t("mask_prompt"), default="255.255.255.0").strip() or "255.255.255.0")
gw = Prompt.ask(t("gw_prompt"), default="").strip()
gw_tail = t("via_gw", gw=gw) if gw else ""
console.print()
console.print(f"[yellow]{t('setting_static', name=nic['Name'], ip=ip, mask=mask, gw_tail=gw_tail)}[/]")
set_static_ip(nic["Name"], ip, mask, gw)
console.print(f"[green]{t('done')}[/]")
_show_current(console, nic)
console.print()
input(t("press_enter"))
if __name__ == "__main__":
main()

View file

@ -1,88 +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.
"""
from __future__ import annotations
import configparser
import sys
from pathlib import Path
SUPPORTED_LANGS = ("en", "ru")
DEFAULT_LANG = "en"
CONFIG_HEADER = """\
# ---------------------------------------------------------------------------
# netswitch 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:
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:
path = config_path()
if not path.exists():
lang = _ask_language()
try:
_write_config(lang)
except OSError:
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

@ -1,83 +0,0 @@
"""
Tiny in-memory translation table.
"""
from __future__ import annotations
_lang = "en"
STRINGS: dict[str, dict[str, str]] = {
"en": {
"tagline": "- NIC IP/DHCP toggle",
"update_available": "Update available ({tag})",
"no_adapters": "No physical wired adapters found.",
"press_enter": "Press Enter to exit",
"available_adapters": "Available adapters:",
"select_adapter": "Select adapter number",
"invalid_selection": "Invalid selection.",
"selected": "Selected: {name}",
"mode_header": "Mode:",
"mode_static": " 1) Static IP",
"mode_dhcp": " 2) DHCP",
"mode_choice": "Choice [1]",
"setting_dhcp": "Setting {name} to DHCP...",
"done": "Done.",
"ip_prompt": "IP address [10.10.10.1]",
"mask_prompt": "Subnet mask [255.255.255.0]",
"gw_prompt": "Gateway (Enter to skip)",
"setting_static": "Setting {name} -> {ip} / {mask}{gw_tail}",
"via_gw": " via {gw}",
"current_config": "Current IPv4 config:",
"col_ip": "IP",
"col_prefix": "Prefix",
"col_origin": "Origin",
"no_ip": "(no IPv4 addresses)",
},
"ru": {
"tagline": "— переключатель NIC IP/DHCP",
"update_available": "Доступно обновление ({tag})",
"no_adapters": "Подходящие проводные адаптеры не найдены.",
"press_enter": "Нажмите Enter для выхода",
"available_adapters": "Доступные адаптеры:",
"select_adapter": "Введите номер адаптера",
"invalid_selection": "Неверный выбор.",
"selected": "Выбрано: {name}",
"mode_header": "Режим:",
"mode_static": " 1) Статический IP",
"mode_dhcp": " 2) DHCP",
"mode_choice": "Выбор [1]",
"setting_dhcp": "Перевожу {name} в режим DHCP...",
"done": "Готово.",
"ip_prompt": "IP-адрес [10.10.10.1]",
"mask_prompt": "Маска подсети [255.255.255.0]",
"gw_prompt": "Шлюз (Enter — пропустить)",
"setting_static": "Назначаю {name} -> {ip} / {mask}{gw_tail}",
"via_gw": " через {gw}",
"current_config": "Текущая конфигурация IPv4:",
"col_ip": "IP",
"col_prefix": "Префикс",
"col_origin": "Источник",
"no_ip": "(нет IPv4-адресов)",
},
}
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:
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

@ -1,107 +0,0 @@
"""
Network plumbing: enumerate physical NICs, set / revert IP via netsh, dump
current IPv4 config for the chosen NIC.
"""
from __future__ import annotations
import json
import os
import subprocess
from typing import Any
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
# Adapters we never show in the picker — same blacklist as dhcpsrv.
SKIP_DESCRIPTION = (
"VPN", "Virtual", "AnyConnect", "TAP-", "TUN-", "Bluetooth", "Loopback",
"WAN Miniport", "Hyper-V", "VMware", "VirtualBox", "WireGuard", "OpenVPN",
"Tailscale", "ZeroTier",
)
SKIP_MEDIA = ("Native 802.11", "Wireless WAN")
def _run_ps(cmd: str, timeout: int = 15) -> subprocess.CompletedProcess:
return subprocess.run(
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", cmd],
capture_output=True, text=True, timeout=timeout,
creationflags=CREATE_NO_WINDOW,
)
def _run_netsh(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess:
return subprocess.run(
["netsh", *args],
capture_output=True, text=True, timeout=timeout,
creationflags=CREATE_NO_WINDOW,
)
def list_adapters() -> list[dict[str, Any]]:
"""Return physical wired adapters only — skip wireless / VPN / virtual."""
cmd = (
r"Get-NetAdapter | ForEach-Object {"
r" $ip = (Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | "
r" Where-Object PrefixOrigin -ne 'WellKnown' | Select-Object -ExpandProperty IPAddress) -join ','; "
r" [pscustomobject]@{"
r" Name=$_.Name; Description=$_.InterfaceDescription; Status=$_.Status; "
r" Virtual=[bool]$_.Virtual; MediaType=$_.MediaType; ifIndex=$_.ifIndex; IPv4=$ip"
r" }} | ConvertTo-Json -Depth 3 -Compress"
)
r = _run_ps(cmd, timeout=20)
if r.returncode != 0 or not r.stdout.strip():
return []
data = json.loads(r.stdout)
if isinstance(data, dict):
data = [data]
out: list[dict[str, Any]] = []
for a in data:
if a.get("Status") in ("Disabled", "Not Present"):
continue
if a.get("Virtual"):
continue
if a.get("MediaType") in SKIP_MEDIA:
continue
haystack = ((a.get("Description") or "") + " " + (a.get("Name") or "")).lower()
if any(k.lower() in haystack for k in SKIP_DESCRIPTION):
continue
out.append(a)
out.sort(key=lambda x: x["ifIndex"])
return out
def set_static_ip(nic_name: str, ip: str, mask: str, gw: str = "") -> None:
args = ["interface", "ipv4", "set", "address", f"name={nic_name}", "static", ip, mask]
if gw:
args.append(gw)
_run_netsh(args)
def revert_to_dhcp(nic_name: str) -> None:
_run_netsh(["interface", "ipv4", "set", "address", f"name={nic_name}", "source=dhcp"])
_run_netsh(["interface", "ipv4", "set", "dnsservers", f"name={nic_name}", "source=dhcp"])
def get_current_ipv4(if_index: int) -> list[dict[str, Any]]:
"""Return non-link-local IPv4 addresses for the interface as
[{ip, prefix_len, prefix_origin}, ...]."""
cmd = (
f"Get-NetIPAddress -InterfaceIndex {if_index} -AddressFamily IPv4 -ErrorAction SilentlyContinue | "
r"Where-Object PrefixOrigin -ne 'WellKnown' | "
r"ForEach-Object { [pscustomobject]@{ "
r" IP=$_.IPAddress; PrefixLength=$_.PrefixLength; PrefixOrigin=[string]$_.PrefixOrigin "
r"}} | ConvertTo-Json -Depth 3 -Compress"
)
r = _run_ps(cmd)
if r.returncode != 0 or not r.stdout.strip():
return []
try:
data = json.loads(r.stdout)
except json.JSONDecodeError:
return []
if isinstance(data, dict):
data = [data]
return [
{"ip": x.get("IP"), "prefix_len": x.get("PrefixLength"), "prefix_origin": x.get("PrefixOrigin")}
for x in data
]

View file

@ -1,43 +0,0 @@
"""
Windows-specific bits: VT (ANSI) processing in the console, UAC self-elevation.
"""
from __future__ import annotations
import ctypes
import os
import sys
def enable_vt() -> None:
"""Enable virtual-terminal processing on the Windows console so that ESC
escape sequences (colours, OSC 8 hyperlinks) are interpreted rather than
printed as literal characters."""
if os.name != "nt":
return
try:
k = ctypes.windll.kernel32
STD_OUT, STD_ERR = -11, -12
ENABLE_VT = 0x0004
for std in (STD_OUT, STD_ERR):
h = k.GetStdHandle(std)
mode = ctypes.c_ulong()
if k.GetConsoleMode(h, ctypes.byref(mode)):
k.SetConsoleMode(h, mode.value | ENABLE_VT)
except Exception:
pass
def is_admin() -> bool:
try:
return ctypes.windll.shell32.IsUserAnAdmin() != 0
except Exception:
return False
def require_admin() -> None:
"""If we're not running elevated, relaunch ourselves through UAC and exit."""
if is_admin():
return
args = " ".join(f'"{a}"' for a in sys.argv)
ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, args, None, 1)
sys.exit(0)

View file

@ -1,35 +0,0 @@
"""Auto-update check. Returns the latest release tag if it is newer than the
currently running version; otherwise None. Silent on any error."""
from __future__ import annotations
import json
import urllib.request
from . import __version__, GITHUB_REPO
def _parse_version(s: str) -> tuple[int, int, int]:
try:
s = (s or "").strip().lstrip("v")
parts = [int(x) for x in s.split(".")[:3]]
while len(parts) < 3:
parts.append(0)
return tuple(parts) # type: ignore[return-value]
except Exception:
return (0, 0, 0)
def check_for_update() -> str | None:
try:
url = f"https://git.engelgardt23.ru/api/v1/repos/{GITHUB_REPO}/releases/latest"
req = urllib.request.Request(url, headers={
"User-Agent": f"netswitch/{__version__}",
})
with urllib.request.urlopen(req, timeout=3) as r:
data = json.loads(r.read().decode("utf-8", errors="replace"))
latest = (data.get("tag_name") or "").strip()
if latest and _parse_version(latest) > _parse_version(__version__):
return latest
except Exception:
pass
return None

View file

@ -1,69 +0,0 @@
# Regenerate assets/icon.ico for this project.
# Edit $Text / colors below and re-run.
param(
[string]$Text = "NET",
[int[]]$ColorFrom = @(120, 220, 140),
[int[]]$ColorTo = @(20, 110, 60),
[string]$OutPath = "$PSScriptRoot\..\assets\icon.ico"
)
Add-Type -AssemblyName System.Drawing
$outDir = Split-Path $OutPath -Parent
if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
$bgFrom = [System.Drawing.Color]::FromArgb(255, $ColorFrom[0], $ColorFrom[1], $ColorFrom[2])
$bgTo = [System.Drawing.Color]::FromArgb(255, $ColorTo[0], $ColorTo[1], $ColorTo[2])
$sizes = 256, 128, 64, 48, 32, 16
$pngs = @()
foreach ($s in $sizes) {
$bmp = New-Object System.Drawing.Bitmap $s, $s
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = 'AntiAlias'
$g.TextRenderingHint = 'AntiAliasGridFit'
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
$r = $s - 2
$path.AddEllipse(1, 1, $r, $r)
$brush = New-Object System.Drawing.Drawing2D.PathGradientBrush($path)
$brush.CenterColor = $bgFrom
$brush.SurroundColors = @($bgTo)
$g.FillEllipse($brush, 1, 1, $r, $r)
$pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(60, 0, 0, 0)), ([float]($s/64))
$g.DrawEllipse($pen, 1, 1, $r, $r)
$fontSize = [float]($s * 0.32)
$font = New-Object System.Drawing.Font "Segoe UI Black", $fontSize, ([System.Drawing.FontStyle]::Bold), ([System.Drawing.GraphicsUnit]::Pixel)
$sf = New-Object System.Drawing.StringFormat
$sf.Alignment = 'Center'; $sf.LineAlignment = 'Center'
$shadow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(80, 0, 0, 0))
$g.DrawString($Text, $font, $shadow, [float]($s/2 + $s*0.02), [float]($s/2 + $s*0.02), $sf)
$g.DrawString($Text, $font, [System.Drawing.Brushes]::White, [float]($s/2), [float]($s/2), $sf)
$g.Dispose()
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$pngs += ,@{ size = $s; bytes = $ms.ToArray() }
$ms.Dispose(); $bmp.Dispose()
}
$fs = [System.IO.File]::Create($OutPath)
$bw = New-Object System.IO.BinaryWriter $fs
$bw.Write([uint16]0)
$bw.Write([uint16]1)
$bw.Write([uint16]$pngs.Count)
$offset = 6 + 16 * $pngs.Count
foreach ($p in $pngs) {
$w = if ($p.size -ge 256) { 0 } else { $p.size }
$bw.Write([byte]$w); $bw.Write([byte]$w)
$bw.Write([byte]0); $bw.Write([byte]0)
$bw.Write([uint16]1); $bw.Write([uint16]32)
$bw.Write([uint32]$p.bytes.Length); $bw.Write([uint32]$offset)
$offset += $p.bytes.Length
}
foreach ($p in $pngs) { $bw.Write($p.bytes) }
$bw.Close(); $fs.Close()
Write-Host "Wrote $OutPath"