Every general-purpose Linux desktop eventually needs a way to flip between "just get me on the network" (DHCP) and "I need a fixed address" (static IP). On GNOME-based systems that's nm-connection-editor, backed by NetworkManager. It's a good tool -- but it's also a genuinely heavy answer to a genuinely small question, and on LinuxDoors we said no to it on purpose.
Why not just build NetworkManager?
NetworkManager isn't a single binary you drop in -- it's a full daemon with its own D-Bus service, its own device-state machine, and a dependency chain that pulls in things like libndp, libnm, plugin backends for every connection type it supports, and (for the GNOME-style editor UI specifically) GObject Introspection bindings on top of that. None of it is bad engineering -- it's just built for a much bigger job than "toggle DHCP on or off for one wired interface," which is the entire problem LinuxDoors actually had and solved right now.
LinuxDoors already runs a clean, minimal network stack: plain systemd-networkd managing the interface via a single .network file, with systemd-resolved handling DNS. That stack works well and boots fast. Bolting an entire second network-management daemon on top of it -- one that would then need to coexist with, or replace, the networkd setup that's already running -- is a lot of new surface area and a lot of new failure modes for a feature that, at its core, is just "write a config file and restart a service."
What we built instead
LinuxDoors Network Settings is a small, native GTK3 app that edits systemd-networkd's own config directly -- no new daemon, no new D-Bus service, nothing running in the background when you're not looking at it. It:
- Shows your interface's real, live status -- address, gateway, DNS -- read straight from
ipandresolvectl, not cached or guessed. - Lets you flip between Automatic (DHCP) and Manual (Static IP) with one click.
- Pre-fills the static-IP fields with whatever your current live connection actually has, so switching modes doesn't mean starting from a blank form.
- Applies a change by writing the real config file, restarting
systemd-networkd, and then polling the live interface to confirm the address you asked for actually came up -- it doesn't just trust that the write succeeded. - Supports multiple DNS servers as a simple comma-separated field, written out as proper repeated
DNS=lines the waysystemd-networkdexpects them.
One real technical wrinkle worth mentioning for anyone doing something similar: systemd-networkd only ever applies the first .network file (in filename order) that matches a given interface -- every other matching file is silently ignored, even if it also matches. So this app doesn't create a second, competing config file; it edits the exact same file the system's own boot-time default already uses. Get that part wrong and you can write a perfectly correct static-IP file that simply never takes effect, with no error anywhere to tell you why.
Tested for real, not just written
Before calling it done, we drove the actual GUI through a real static-IP apply, confirmed the interface picked up a genuine static address (not just a lucky match against the old DHCP lease), confirmed the multi-DNS field writes out correctly with two independent servers, and confirmed real internet connectivity -- DNS resolution and ping -- survived both changes and a rollback back to DHCP. No shortcuts, no assumptions.
It's a small app doing one job well, matching the same philosophy behind the rest of the LinuxDoors desktop stack: pick the lightest tool that actually solves the problem in front of you, not the biggest one that could theoretically solve every problem you might have someday.
Perhaps I should of guided this as a Python3 script, no matter, programming AI assist source code.
#!/bin/bash
# LinuxDoors Network Settings -- a GTK3 app for switching between automatic
# DHCP and a fixed static IP (address/subnet/gateway/DNS), matching the
# same visual language (Cairo-drawn header mark, gold/violet card CSS,
# silent-default-password launcher) as Make Secure LinuxDoors and the
# Phase 1.5 installer. Run once inside the chroot, after the GTK3+PyGObject
# stack those apps already use exists (same toolkit, same reasoning).
#
# Real design decision, worth documenting: systemd-networkd only applies
# the FIRST matching .network file (in lexical filename order) to a given
# interface -- all later matches are ignored entirely, even if they also
# match. `lfs_system_config.sh` already ships
# /etc/systemd/network/20-wired-dhcp.network with `[Match] Type=ether` /
# `DHCP=yes`. Rather than add a second, competing .network file (which
# would silently do nothing, since 20-wired-dhcp.network would keep
# winning regardless of what a new file says), this app edits that exact
# same file in place -- toggling its [Network] section between `DHCP=yes`
# and a real `Address=`/`Gateway=`/`DNS=` static block -- so there is only
# ever one file matching the interface, and it's always the one this app
# (and the system's own boot-time default) both agree on.
set -e
mkdir -pv /usr/local/bin
cat > /usr/local/bin/linuxdoors-network << "PYEOF"
#!/usr/bin/env python3
"""LinuxDoors Network Settings -- switch between DHCP and a static IP.
Must run as root (writes /etc/systemd/network/20-wired-dhcp.network and
restarts systemd-networkd)."""
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib
import cairo
import math
import os
import re
import shutil
import subprocess
import sys
import threading
import time
if os.geteuid() != 0:
print("linuxdoors-network must run as root", file=sys.stderr)
sys.exit(1)
NETWORK_FILE = "/etc/systemd/network/20-wired-dhcp.network"
BACKUP_FILE = NETWORK_FILE + ".bak"
CSS = b"""
window, .background {
background-image: linear-gradient(to bottom, #1c1038, #0a0616);
background-color: #1c1038;
}
.title { color: #fbeed2; font-weight: bold; font-size: 22px; }
.subtitle { color: #b8a4d8; font-size: 13px; }
.headline { color: #fbeed2; font-weight: bold; font-size: 15px; }
.card {
background-color: #201038;
border-radius: 14px;
border: 1px solid #3a2570;
}
button {
border-radius: 8px;
padding: 8px 16px;
border: none;
background-image: none;
transition: background-color 150ms ease;
}
button.suggested-action {
background-color: #e8c98a;
background-image: none;
color: #1c1038;
font-weight: bold;
}
button.suggested-action label { color: #1c1038; }
button.suggested-action:hover { background-color: #f6d9a0; }
button.flat-nav {
background-color: #241246;
background-image: none;
color: #cdbdec;
border: 1px solid #3a2570;
}
button.flat-nav label { color: #cdbdec; }
button.flat-nav:hover { background-color: #33205e; }
button.link-small {
background-color: transparent;
background-image: none;
color: #e8c98a;
border: none;
padding: 2px 4px;
font-size: 12px;
}
button.link-small label { color: #e8c98a; }
button.link-small:hover { background-color: #2a1750; }
radiobutton label { color: #fbeed2; }
label { color: #fbeed2; }
button label { color: #1c1038; }
.detail-ok { color: #9ad38a; }
.detail-bad { color: #e0a53a; }
.field-error { color: #e0806a; font-size: 12px; }
entry {
background-color: #150a2a;
color: #fbeed2;
border-radius: 8px;
border: 1px solid #3a2570;
padding: 6px 10px;
}
spinbutton {
background-color: #150a2a;
color: #fbeed2;
border-radius: 8px;
border: 1px solid #3a2570;
}
spinbutton entry { border: none; }
textview, textview text { background-color: #150a2a; color: #cdbdec; }
"""
DOOR_GOLD = (0xe8 / 255, 0xc9 / 255, 0x8a / 255)
def draw_network_icon(_widget, cr, w, h):
"""Header mark: a signal/broadcast glyph (concentric arcs over a dot),
the standard universal "network" symbol -- distinct from the door
mark, same reasoning Make Secure LinuxDoors used for its own shield:
a tool with a specific job reads correctly at a glance with its own
icon, kept in the same gold/violet family for brand consistency."""
cr.save()
cx, cy = w / 2, h * 0.8
dot_r = min(w, h) * 0.075
cr.set_source_rgb(*DOOR_GOLD)
cr.arc(cx, cy, dot_r, 0, 2 * math.pi)
cr.fill()
cr.set_line_cap(cairo.LINE_CAP_ROUND)
for frac in (0.30, 0.50, 0.70):
r = min(w, h) * frac
cr.set_line_width(max(2, min(w, h) * 0.05))
cr.set_source_rgb(*DOOR_GOLD)
cr.arc(cx, cy, r, -3 * math.pi / 4, -math.pi / 4)
cr.stroke()
cr.restore()
return False
IPV4_RE = re.compile(r"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$")
def is_valid_ipv4(s):
m = IPV4_RE.match(s.strip())
if not m:
return False
return all(0 <= int(g) <= 255 for g in m.groups())
def list_interfaces():
"""Every real, currently-present non-loopback interface -- populates
the device picker. Not filtered to Ethernet only, so this still shows
something sensible if a future system has other interface types."""
try:
names = sorted(os.listdir("/sys/class/net"))
except OSError:
return []
return [n for n in names if n != "lo"]
def get_interface():
"""Real, not guessed: the first non-loopback Ethernet (ARPHRD_ETHER,
type 1) interface, used as the default pre-selected device -- the
boot-time default .network file's own `Type=ether` match picks up
exactly this kind of interface, so this is the natural default."""
for name in list_interfaces():
try:
with open(f"/sys/class/net/{name}/type") as f:
if f.read().strip() == "1":
return name
except OSError:
continue
names = list_interfaces()
return names[0] if names else None
def read_saved_config():
"""Parses the real, currently-deployed NETWORK_FILE -- not a cached
guess -- so the UI always reflects what's actually on disk."""
cfg = {"mode": "dhcp", "address": None, "prefix": None,
"gateway": None, "dns": []}
try:
with open(NETWORK_FILE) as f:
lines = f.readlines()
except FileNotFoundError:
return cfg
for line in lines:
s = line.strip()
if s.startswith("DHCP=") and s.split("=", 1)[1].strip().lower() == "yes":
cfg["mode"] = "dhcp"
elif s.startswith("Address="):
val = s.split("=", 1)[1].strip()
if "/" in val:
addr, prefix = val.split("/", 1)
cfg["address"] = addr
try:
cfg["prefix"] = int(prefix)
except ValueError:
pass
cfg["mode"] = "static"
elif s.startswith("Gateway="):
cfg["gateway"] = s.split("=", 1)[1].strip()
elif s.startswith("DNS="):
cfg["dns"].append(s.split("=", 1)[1].strip())
return cfg
def get_live_state(iface):
"""Real, live kernel/resolver state via `ip`/`resolvectl` -- not the
saved config file, which may not match what's actually running yet
(e.g. right after a change, or before the first DHCP lease)."""
state = {"address": None, "prefix": None, "gateway": None, "dns": []}
if not iface:
return state
try:
r = subprocess.run(["ip", "-4", "-o", "addr", "show", "dev", iface],
capture_output=True, text=True, timeout=5)
for line in r.stdout.splitlines():
parts = line.split()
if "inet" in parts:
cidr = parts[parts.index("inet") + 1]
if "/" in cidr:
addr, prefix = cidr.split("/")
state["address"] = addr
try:
state["prefix"] = int(prefix)
except ValueError:
pass
break
except Exception:
pass
try:
r = subprocess.run(["ip", "-4", "route", "show", "default", "dev", iface],
capture_output=True, text=True, timeout=5)
for line in r.stdout.splitlines():
parts = line.split()
if "via" in parts:
state["gateway"] = parts[parts.index("via") + 1]
break
except Exception:
pass
try:
r = subprocess.run(["resolvectl", "dns", iface],
capture_output=True, text=True, timeout=5)
if r.returncode == 0:
for line in r.stdout.splitlines():
if ":" in line:
rest = line.split(":", 1)[1].strip()
if rest:
state["dns"] = rest.split()
break
except Exception:
pass
if not state["dns"]:
try:
with open("/etc/resolv.conf") as f:
for line in f:
if line.startswith("nameserver"):
state["dns"].append(line.split()[1])
except OSError:
pass
return state
def apply_config(mode, iface, address, prefix, gateway, dns_list, log):
"""Writes NETWORK_FILE (backing up the previous one first), restarts
systemd-networkd, then polls the real live state until it matches
what was requested or a 10s timeout passes -- never just trusts the
write succeeded, since the whole point is confirming the change took.
The [Match] block targets the exact selected device (`Name=<iface>`)
rather than the boot-time default's generic `Type=ether` -- once a
user has explicitly picked a device from the picker, that's a precise
choice worth honoring exactly, especially on any future system with
more than one wired interface."""
lines = ["[Match]", f"Name={iface}", "", "[Network]"]
if mode == "dhcp":
lines.append("DHCP=yes")
else:
lines.append(f"Address={address}/{prefix}")
lines.append(f"Gateway={gateway}")
for d in dns_list:
lines.append(f"DNS={d}")
content = "\n".join(lines) + "\n"
if os.path.exists(NETWORK_FILE):
shutil.copy2(NETWORK_FILE, BACKUP_FILE)
log(f"Backed up previous config to {BACKUP_FILE}")
with open(NETWORK_FILE, "w") as f:
f.write(content)
log(f"Wrote {NETWORK_FILE}:")
for l in content.splitlines():
if l:
log(" " + l)
log("")
log("$ systemctl restart systemd-networkd")
r = subprocess.run(["systemctl", "restart", "systemd-networkd"],
capture_output=True, text=True, timeout=20)
out = (r.stdout + r.stderr).strip()
if out:
log(" " + out.replace("\n", "\n "))
if r.returncode != 0:
log(" -> systemd-networkd restart failed, see above.")
return False
log("Waiting for the interface to come up...")
deadline = time.time() + 10
got = None
while time.time() < deadline:
state = get_live_state(iface)
if mode == "dhcp" and state["address"]:
got = state
break
if mode == "static" and state["address"] == address:
got = state
break
time.sleep(1)
if got:
line = f" Interface {iface} is now {got['address']}/{got['prefix']}"
if got["gateway"]:
line += f" via {got['gateway']}"
log(line)
if got["dns"]:
log(f" DNS: {', '.join(got['dns'])}")
log("")
log("Done.")
return True
log(" Did not see the expected address come up within 10s -- "
"check the log above, or click Refresh to see the current real "
"state (a DHCP lease can occasionally take a little longer).")
return False
class NetworkWindow(Gtk.Window):
def __init__(self):
super().__init__(title="LinuxDoors Network Settings")
self.set_default_size(600, 560)
self.set_position(Gtk.WindowPosition.CENTER)
self.interfaces = list_interfaces()
self.iface = get_interface()
# The whole window's content sits inside a vertical-only scroller,
# so on a short screen (or with the static-IP fields expanded) the
# window gets a real scrollbar instead of being clipped or forced
# taller than the display.
outer_scroller = Gtk.ScrolledWindow()
outer_scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self.add(outer_scroller)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
outer.set_border_width(24)
outer_scroller.add(outer)
header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14)
icon = Gtk.DrawingArea()
icon.set_size_request(48, 58)
icon.connect("draw", lambda w, cr: draw_network_icon(w, cr, 48, 58))
header.pack_start(icon, False, False, 0)
title_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
title = Gtk.Label(label="LinuxDoors Network Settings", xalign=0)
title.get_style_context().add_class("title")
subtitle = Gtk.Label(
label="Automatic (DHCP) or a fixed static IP -- your choice",
xalign=0)
subtitle.get_style_context().add_class("subtitle")
title_box.pack_start(title, False, False, 0)
title_box.pack_start(subtitle, False, False, 0)
header.pack_start(title_box, False, False, 0)
outer.pack_start(header, False, False, 0)
if not self.interfaces:
err = Gtk.Label(
label="No network interface was found on this system.",
xalign=0)
err.get_style_context().add_class("detail-bad")
outer.pack_start(err, False, False, 0)
else:
device_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
device_label = Gtk.Label(label="Network Device:", xalign=0)
device_box.pack_start(device_label, False, False, 0)
self.device_combo = Gtk.ComboBoxText()
for name in self.interfaces:
self.device_combo.append_text(name)
active_index = (self.interfaces.index(self.iface)
if self.iface in self.interfaces else 0)
self.device_combo.set_active(active_index)
self.iface = self.interfaces[active_index]
self.device_combo.connect("changed", self.on_device_changed)
device_box.pack_start(self.device_combo, False, False, 0)
outer.pack_start(device_box, False, False, 0)
# -- Current status card --------------------------------------
status_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
status_card.get_style_context().add_class("card")
status_card.set_border_width(16)
status_headline = Gtk.Label(label="Current status", xalign=0)
status_headline.get_style_context().add_class("headline")
status_card.pack_start(status_headline, False, False, 0)
self.status_iface = Gtk.Label(xalign=0)
self.status_mode = Gtk.Label(xalign=0)
self.status_addr = Gtk.Label(xalign=0)
self.status_gw = Gtk.Label(xalign=0)
self.status_dns = Gtk.Label(xalign=0)
for lbl in (self.status_iface, self.status_mode, self.status_addr,
self.status_gw, self.status_dns):
status_card.pack_start(lbl, False, False, 0)
outer.pack_start(status_card, False, False, 0)
# -- Configuration card ------------------------------------------
config_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
config_card.get_style_context().add_class("card")
config_card.set_border_width(16)
config_headline = Gtk.Label(label="Configuration", xalign=0)
config_headline.get_style_context().add_class("headline")
config_card.pack_start(config_headline, False, False, 0)
self.dhcp_radio = Gtk.RadioButton.new_with_label(None, "Automatic (DHCP)")
self.static_radio = Gtk.RadioButton.new_with_label_from_widget(
self.dhcp_radio, "Manual (Static IP)")
config_card.pack_start(self.dhcp_radio, False, False, 0)
config_card.pack_start(self.static_radio, False, False, 0)
self.revealer = Gtk.Revealer()
self.revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
self.revealer.set_transition_duration(220)
static_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
static_box.set_margin_top(6)
grid = Gtk.Grid(row_spacing=8, column_spacing=12)
grid.set_margin_start(20)
def add_row(row, text, widget):
lbl = Gtk.Label(label=text, xalign=0)
grid.attach(lbl, 0, row, 1, 1)
grid.attach(widget, 1, row, 1, 1)
self.ip_entry = Gtk.Entry()
self.ip_entry.set_hexpand(True)
add_row(0, "IP Address", self.ip_entry)
self.prefix_spin = Gtk.SpinButton.new_with_range(1, 32, 1)
self.prefix_spin.set_value(24)
add_row(1, "Subnet Prefix (CIDR)", self.prefix_spin)
self.gateway_entry = Gtk.Entry()
self.gateway_entry.set_hexpand(True)
add_row(2, "Gateway", self.gateway_entry)
self.dns_entry = Gtk.Entry()
self.dns_entry.set_hexpand(True)
self.dns_entry.set_placeholder_text("e.g. 8.8.8.8, 1.1.1.1")
add_row(3, "DNS Servers (comma-separated)", self.dns_entry)
static_box.pack_start(grid, False, False, 0)
fill_btn = Gtk.Button(label="Fill in current values")
fill_btn.get_style_context().add_class("link-small")
fill_btn.set_halign(Gtk.Align.START)
fill_btn.set_margin_start(20)
fill_btn.connect("clicked", self.on_fill_current)
static_box.pack_start(fill_btn, False, False, 0)
self.field_error = Gtk.Label(xalign=0)
self.field_error.get_style_context().add_class("field-error")
self.field_error.set_margin_start(20)
static_box.pack_start(self.field_error, False, False, 0)
self.revealer.add(static_box)
config_card.pack_start(self.revealer, False, False, 0)
outer.pack_start(config_card, False, False, 0)
self.dhcp_radio.connect("toggled", self.on_mode_toggled)
self.static_radio.connect("toggled", self.on_mode_toggled)
# -- Buttons -------------------------------------------------
btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.apply_btn = Gtk.Button(label="Apply")
self.apply_btn.get_style_context().add_class("suggested-action")
self.apply_btn.connect("clicked", self.on_apply_clicked)
btn_row.pack_start(self.apply_btn, False, False, 0)
refresh_btn = Gtk.Button(label="Refresh")
refresh_btn.get_style_context().add_class("flat-nav")
refresh_btn.connect("clicked", lambda *_: self.refresh_status())
btn_row.pack_start(refresh_btn, False, False, 0)
outer.pack_start(btn_row, False, False, 0)
log_label = Gtk.Label(label="What happened:", xalign=0)
log_label.get_style_context().add_class("subtitle")
outer.pack_start(log_label, False, False, 0)
scroller = Gtk.ScrolledWindow()
scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroller.set_min_content_height(150)
self.log_view = Gtk.TextView()
self.log_view.set_editable(False)
self.log_view.set_cursor_visible(False)
self.log_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
self.log_buffer = self.log_view.get_buffer()
scroller.add(self.log_view)
outer.pack_start(scroller, False, False, 0)
self._load_initial_state()
self.refresh_status()
def _load_initial_state(self):
saved = read_saved_config()
live = get_live_state(self.iface)
if saved["mode"] == "static":
self.static_radio.set_active(True)
self.ip_entry.set_text(saved["address"] or "")
self.prefix_spin.set_value(saved["prefix"] or 24)
self.gateway_entry.set_text(saved["gateway"] or "")
self.dns_entry.set_text(", ".join(saved["dns"]))
else:
self.dhcp_radio.set_active(True)
if live["address"]:
self.ip_entry.set_text(live["address"])
if live["prefix"]:
self.prefix_spin.set_value(live["prefix"])
if live["gateway"]:
self.gateway_entry.set_text(live["gateway"])
if live["dns"]:
self.dns_entry.set_text(", ".join(live["dns"]))
self.on_mode_toggled(None)
def on_device_changed(self, combo):
name = combo.get_active_text()
if name and name != self.iface:
self.iface = name
self.refresh_status()
def on_fill_current(self, _btn):
live = get_live_state(self.iface)
if live["address"]:
self.ip_entry.set_text(live["address"])
if live["prefix"]:
self.prefix_spin.set_value(live["prefix"])
if live["gateway"]:
self.gateway_entry.set_text(live["gateway"])
if live["dns"]:
self.dns_entry.set_text(", ".join(live["dns"]))
def on_mode_toggled(self, _btn):
self.revealer.set_reveal_child(self.static_radio.get_active())
def log(self, line):
def do_append():
end = self.log_buffer.get_end_iter()
self.log_buffer.insert(end, line + "\n")
self.log_view.scroll_to_iter(self.log_buffer.get_end_iter(), 0, False, 0, 0)
return False
GLib.idle_add(do_append)
def refresh_status(self):
saved = read_saved_config()
live = get_live_state(self.iface)
self.status_iface.set_text(f"Interface: {self.iface or '(none found)'}")
mode_text = "Automatic (DHCP)" if saved["mode"] == "dhcp" else "Manual (Static IP)"
self.status_mode.set_text(f"Configured mode: {mode_text}")
def mark(ok, text):
cls = "detail-ok" if ok else "detail-bad"
return f'<span foreground="{"#9ad38a" if ok else "#e0a53a"}">{text}</span>'
if live["address"]:
self.status_addr.set_markup(mark(
True, f"Address: {live['address']}/{live['prefix']}"))
else:
self.status_addr.set_markup(mark(False, "Address: none (not connected)"))
self.status_gw.set_markup(mark(
bool(live["gateway"]), f"Gateway: {live['gateway'] or '(none)'}"))
self.status_dns.set_markup(mark(
bool(live["dns"]), f"DNS: {', '.join(live['dns']) if live['dns'] else '(none)'}"))
def on_apply_clicked(self, _btn):
self.field_error.set_text("")
if not self.iface:
self.field_error.set_text("No network interface to configure.")
return
if self.dhcp_radio.get_active():
mode = "dhcp"
address = prefix = gateway = None
dns_list = []
else:
mode = "static"
address = self.ip_entry.get_text().strip()
prefix = int(self.prefix_spin.get_value())
gateway = self.gateway_entry.get_text().strip()
dns_raw = self.dns_entry.get_text().strip()
dns_list = [d.strip() for d in dns_raw.split(",") if d.strip()]
if not is_valid_ipv4(address):
self.field_error.set_text("IP Address is not a valid IPv4 address.")
return
if gateway and not is_valid_ipv4(gateway):
self.field_error.set_text("Gateway is not a valid IPv4 address.")
return
if not gateway:
self.field_error.set_text("Gateway is required for a static IP.")
return
for d in dns_list:
if not is_valid_ipv4(d):
self.field_error.set_text(f"'{d}' is not a valid DNS server address.")
return
self.apply_btn.set_sensitive(False)
self.log_buffer.set_text("")
self.log(f"Applying {'automatic DHCP' if mode == 'dhcp' else 'static IP'} "
f"configuration to {self.iface}...")
self.log("")
def worker():
apply_config(mode, self.iface, address, prefix, gateway, dns_list, self.log)
def done():
self.refresh_status()
self.apply_btn.set_sensitive(True)
return False
GLib.idle_add(done)
threading.Thread(target=worker, daemon=True).start()
def main():
screen = Gdk.Screen.get_default()
provider = Gtk.CssProvider()
provider.load_from_data(CSS)
Gtk.StyleContext.add_provider_for_screen(
screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
win = NetworkWindow()
win.connect("destroy", Gtk.main_quit)
win.show_all()
win.on_mode_toggled(None)
Gtk.main()
if __name__ == "__main__":
main()
PYEOF
chmod 755 /usr/local/bin/linuxdoors-network
echo "== linuxdoors-network unprivileged launcher =="
cat > /usr/local/bin/linuxdoors-network-launcher << "PYEOF"
#!/usr/bin/env python3
"""LinuxDoors Network Settings -- unprivileged launcher. Runs as the
logged-in user (no root needed for this part). Tries the well-known
LinuxDoors default root password automatically, silently, over a real
pty -- same technique as every other silent-default-password launcher in
this project -- so the common case (password never changed) needs zero
typing and no visible prompt. Only if that's rejected does it fall back
to a real masked-password GTK dialog, retrying until the user gets it
right or cancels."""
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
import os
import pty
import select
APP = "/usr/local/bin/linuxdoors-network"
DEFAULT_PASSWORD = "linuxdoors"
CSS = b"""
window, .background {
background-image: linear-gradient(to bottom, #1c1038, #0a0616);
background-color: #1c1038;
}
label { color: #fbeed2; }
entry {
background-color: #241246;
color: #fbeed2;
border-radius: 8px;
border: 1px solid #3a2570;
padding: 6px 10px;
}
button {
border-radius: 8px;
padding: 8px 16px;
border: none;
background-image: none;
}
button.suggested-action {
background-color: #e8c98a;
color: #1c1038;
font-weight: bold;
}
button.suggested-action label { color: #1c1038; }
button label { color: #1c1038; }
button.flat-nav {
background-color: #241246;
color: #cdbdec;
border: 1px solid #3a2570;
}
button.flat-nav label { color: #cdbdec; }
"""
def try_su(password):
"""Runs `su -c APP root` under a real pty, feeding `password` once a
Password: prompt appears. Blocks until APP itself exits (keeping the
pty open the whole time -- closing it early would SIGHUP the root-
owned app). Returns True if the password was accepted."""
pid, fd = pty.fork()
if pid == 0:
os.execvp("su", ["su", "-c", APP, "root"])
sent = False
auth_failed = False
while True:
try:
r, _, _ = select.select([fd], [], [], 60)
except OSError:
break
if fd not in r:
continue
try:
data = os.read(fd, 4096)
except OSError:
break
if not data:
break
low = data.lower()
if not sent and b"password" in low:
os.write(fd, (password + "\n").encode())
sent = True
elif sent and (b"authentication failure" in low or b"incorrect password" in low):
auth_failed = True
break
os.waitpid(pid, 0)
return sent and not auth_failed
WHY_TEXT = "This is LinuxDoors Network Settings. A password is required to start this program."
def ask_password(retry):
dialog = Gtk.Dialog(title="LinuxDoors Network Settings")
screen = dialog.get_screen()
provider = Gtk.CssProvider()
provider.load_from_data(CSS)
Gtk.StyleContext.add_provider_for_screen(
screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
dialog.set_default_size(360, 170)
dialog.set_position(Gtk.WindowPosition.CENTER)
cancel_btn = dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
cancel_btn.get_style_context().add_class("flat-nav")
ok_btn = dialog.add_button("Unlock", Gtk.ResponseType.OK)
ok_btn.get_style_context().add_class("suggested-action")
dialog.set_default(ok_btn)
box = dialog.get_content_area()
box.set_spacing(10)
box.set_border_width(18)
why_label = Gtk.Label(label=WHY_TEXT)
why_label.set_xalign(0)
why_label.set_line_wrap(True)
why_label.get_style_context().add_class("subtitle")
box.pack_start(why_label, False, False, 0)
if retry:
retry_label = Gtk.Label(label="That password wasn't accepted -- try again.")
retry_label.set_xalign(0)
box.pack_start(retry_label, False, False, 0)
entry_label = Gtk.Label(label="Password for root:")
entry_label.set_xalign(0)
box.pack_start(entry_label, False, False, 0)
entry = Gtk.Entry()
entry.set_visibility(False)
entry.set_invisible_char("*")
entry.set_activates_default(True)
box.pack_start(entry, False, False, 0)
dialog.show_all()
response = dialog.run()
password = entry.get_text()
dialog.destroy()
if response != Gtk.ResponseType.OK:
return None
return password
def main():
if try_su(DEFAULT_PASSWORD):
return
retry = False
while True:
password = ask_password(retry)
if password is None:
return
if try_su(password):
return
retry = True
if __name__ == "__main__":
main()
PYEOF
chmod 755 /usr/local/bin/linuxdoors-network-launcher
echo "== linuxdoors-network icon =="
python3 << "PYEOF"
import cairo
import math
import os
DOOR_GOLD = (0xe8 / 255, 0xc9 / 255, 0x8a / 255)
def draw_icon(cr, size):
cx, cy = size / 2, size * 0.8
dot_r = size * 0.075
cr.set_source_rgb(*DOOR_GOLD)
cr.arc(cx, cy, dot_r, 0, 2 * math.pi)
cr.fill()
cr.set_line_cap(cairo.LINE_CAP_ROUND)
for frac in (0.30, 0.50, 0.70):
r = size * frac
cr.set_line_width(max(1.5, size * 0.05))
cr.set_source_rgb(*DOOR_GOLD)
cr.arc(cx, cy, r, -3 * math.pi / 4, -math.pi / 4)
cr.stroke()
for size in (16, 22, 24, 32, 48, 64, 128, 256):
out_dir = f"/usr/share/icons/hicolor/{size}x{size}/apps"
os.makedirs(out_dir, exist_ok=True)
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size)
cr = cairo.Context(surface)
draw_icon(cr, size)
surface.write_to_png(os.path.join(out_dir, "linuxdoors-network.png"))
print("icon generated at all sizes")
PYEOF
command -v gtk-update-icon-cache >/dev/null 2>&1 && \
gtk-update-icon-cache -f -t /usr/share/icons/hicolor || true
echo "== desktop entries =="
mkdir -pv /usr/share/applications
cat > /usr/share/applications/linuxdoors-network.desktop << "EOF"
[Desktop Entry]
Type=Application
Version=1.0
Name=Network Settings LinuxDoors
Comment=Switch between automatic DHCP and a fixed static IP
Icon=linuxdoors-network
Exec=/usr/local/bin/linuxdoors-network-launcher
Terminal=false
Categories=System;Network;Settings;
EOF
chmod 644 /usr/share/applications/linuxdoors-network.desktop
cp -v /usr/share/applications/linuxdoors-network.desktop /home/linuxdoors/Desktop/linuxdoors-network.desktop
chmod 755 /home/linuxdoors/Desktop/linuxdoors-network.desktop
chown linuxdoors:users /home/linuxdoors/Desktop/linuxdoors-network.desktop
echo "== done =="
0 Comments
No comments yet. Be the first!
Leave a Comment