XFCE

Why LinuxDoors Skips NetworkManager: Meet LinuxDoors Network Settings

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.

LinuxDoors Network Settings showing live DHCP status

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 ip and resolvectl, 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 way systemd-networkd expects 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.

Bash
#!/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