diff --git a/APPIMAGE.md b/APPIMAGE.md
index e6c71eb..cf32ba8 100644
--- a/APPIMAGE.md
+++ b/APPIMAGE.md
@@ -738,11 +738,11 @@ bundling format inside a `.deb` — legitimate, but then use `/opt` + `$ORIGIN`
- **apt gets strict in 26.04, not 24.04.** apt 3.2 uses Sequoia; `apt-key` is gone;
`Signed-By` must point at a dearmored keyring in `/usr/share/keyrings`, never
`/etc/apt/trusted.gpg.d` (which would trust our key for *all* repositories).
-- **Desktop-file naming**: `main.cpp:67` uses app id `network.ur.urnetwork` but the
+- **Desktop-file naming**: `main.cpp:67` uses app id `com.bringyour.network` but the
desktop file is `urnetwork.desktop`. Nothing is broken today
(`StartupWMClass` covers window association), but D-Bus activation — the clean way to
deliver a `urnetwork://` URI to a running instance — requires the names to match.
- Rename to `network.ur.urnetwork.desktop`.
+ Rename to `com.bringyour.network.desktop`.
### 10d. The recommended alternative, if the direction changes
@@ -903,7 +903,7 @@ working with no error anywhere — a changed type is a decode error, and a chang
in from `postinst` (IVPN's latent bug). **`install.sh` must run
`update-desktop-database` and `gtk-update-icon-cache` itself** — see the §5
callout for why this one silently passes testing and reaches users broken.
-- [ ] Rename the desktop file to `network.ur.urnetwork.desktop` to match
+- [ ] Rename the desktop file to `com.bringyour.network.desktop` to match
`main.cpp:67`'s app id. Harmless today, but D-Bus activation — the clean way to
hand a `urnetwork://` URI to a running instance — requires the names to match.
- [ ] Autostart via a root-owned **inert template** (`/etc/urnetwork/autostart/`) that
diff --git a/MIGRATION.md b/MIGRATION.md
index 30b9876..0d8b627 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -37,12 +37,12 @@ a known follow-up, do not silently pretend loopback is private.
| `/usr/lib/urnetwork/libURnetworkSdk.so` | daemon pkg | rpath `$ORIGIN` |
| `/usr/bin/urnetwork` | daemon pkg | **launcher script**, the stable `Exec=` target |
| `/lib/systemd/system/urnetworkd.service` | daemon pkg | `/lib`, in every release's load path |
-| `/usr/share/applications/network.ur.urnetwork.desktop` | daemon pkg | filename **must** match `main.cpp`'s app id |
-| `/usr/share/icons/hicolor/{48x48,256x256}/apps/urnetwork.png` | daemon pkg | |
+| `/usr/share/applications/com.bringyour.network.desktop` | daemon pkg | filename **must** match `main.cpp`'s app id |
+| `/usr/share/icons/hicolor/{48x48,256x256}/apps/com.bringyour.network.png` | daemon pkg | |
| `/usr/share/urnetwork/world-110m.json` | daemon pkg | globe land outlines |
| `/usr/share/urnetwork/icons/urnetwork-tray-*.png` | daemon pkg | tray art |
| `/usr/share/locale//LC_MESSAGES/urnetwork.mo` | daemon pkg | gettext catalogs |
-| `/etc/urnetwork/autostart/network.ur.urnetwork.desktop` | daemon pkg | **inert template**, GUI symlinks it |
+| `/etc/urnetwork/autostart/com.bringyour.network.desktop` | daemon pkg | **inert template**, GUI symlinks it |
| `/etc/NetworkManager/conf.d/95-urnetwork.conf` | daemon pkg | `unmanaged-devices=interface-name:urnet0` |
| `/etc/udev/rules.d/85-urnetwork-unmanaged.rules` | daemon pkg | `ENV{NM_UNMANAGED}="1"` |
| `~/.local/lib/urnetwork/URnetwork.AppImage` | **user** | never packaged; must be user-writable |
@@ -57,27 +57,51 @@ prints a one-line install hint and exits 127.
## Artifact filenames (normative — the pipeline greps for these)
```
-urnetwork-daemon__.deb arch = amd64 | arm64
+urnetwork-daemon__.deb arch = amd64 | arm64
urnetwork-daemon--.install.tar.gz
+urnetwork-daemon--.pkg.tar.zst pacmanarch = x86_64 | aarch64
URnetwork--.AppImage
URnetwork--.AppImage.zsync
```
+The pacman package is named with pacman's own arch spelling, for the same reason the
+`.rpm` is named with rpm's: a package whose filename disagrees with the arch in its own
+metadata is the confusing artifact. Its `` is the release version verbatim —
+pacman does not parse filenames (`pacman -U ./file` reads `.PKGINFO`), so the file is
+free to be named for the release while the metadata carries the folded, pacman-legal
+`pkgver` (see `pkg_fields()` in `packaging/make-arch.sh`). `make-arch.sh` prints the
+canonical `---.pkg.tar.zst` on every build and emits it
+instead under `UR_ARCH_CANONICAL_NAME=1`, which is what a `repo-add` repository expects.
+
+**The `.rpm` still has no row here.** rpm forbids `-` in both Version and Release while
+this line's `` contains two, so no legal rpm filename can carry the version
+string verbatim. Its asset name is therefore settled by the release workflow rather
+than by this contract, and that remains an open item.
+
`` = `$EXTERNAL_WARP_VERSION`. The tarball's **single top-level directory** is
`urnetwork-daemon/`, containing `install.sh`, `uninstall.sh`, `VERSION`, and a
`payload/` tree mirroring the installed paths above.
### Packaging script names + invocation (pinned 2026-08-05)
-The pipeline calls these three by exact path. **These names are normative**; the
+The pipeline calls these by exact path. **These names are normative**; the
original contract pinned only the output filenames, which left the pipeline guessing:
```
linux/packaging/make-deb.sh
linux/packaging/make-install-tarball.sh
linux/packaging/make-appimage.sh
+linux/packaging/make-rpm.sh (added later; Fedora/RHEL/openSUSE)
+linux/packaging/make-arch.sh (added later; Arch/CachyOS/EndeavourOS/Manjaro)
```
+The two later ones take the SAME four-variable environment as the original three and
+are deliberately not special: each is one more wrapper around the single staging tree,
+so the daemon inside every package is the same bytes by construction rather than by
+review. The pipeline may treat either as optional (`UR_REQUIRE_RPM` /
+`UR_REQUIRE_ARCH_PKG`) — a new package that cannot build must never take the
+already-contracted assets off a release with it.
+
Each is invoked with this environment and **must write its normative artifact
filename into `$OUT_DIR`**:
diff --git a/PLAN.md b/PLAN.md
index 49c92b0..dedbcf5 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -302,7 +302,7 @@ than being the one platform that differs.
refreshed by **dpkg triggers**, no dependency or maintainer script needed; §9.7.1
covers `x-scheme-handler/urnetwork`. ⚠️ Triggers fire only for files **dpkg**
installs, so never symlink one in from `postinst` (IVPN's latent bug). Residual:
- `main.cpp:67`'s app id `network.ur.urnetwork` does not match
+ `main.cpp:67`'s app id `com.bringyour.network` does not match
`urnetwork.desktop`; harmless today, but D-Bus activation requires them to match.
- **R8 — GeoClue ≥ 2.7.0 is required for the location override** [confirmed]:
Ubuntu 22.04 (2.5.7) and Debian 12 (2.6.0) can **never** support it. Those users
diff --git a/app/meson.build b/app/meson.build
index 2fad86a..ccda761 100644
--- a/app/meson.build
+++ b/app/meson.build
@@ -305,30 +305,30 @@ endif
# host_integration=false (Flatpak) drops it with the rest: the GUI never calls
# polkit — the daemon does, against a subject the GUI cannot influence.
if host_integration
-install_data('..' / 'packaging' / 'polkit' / 'network.ur.urnetwork.policy',
+install_data('..' / 'packaging' / 'polkit' / 'com.bringyour.network.policy',
install_dir : get_option('datadir') / 'polkit-1' / 'actions')
endif
-# desktop file: the filename MUST match main.cpp's app id network.ur.urnetwork
+# desktop file: the filename MUST match main.cpp's app id com.bringyour.network
# (D-Bus activation requires it; APPIMAGE.md §11d)
-install_data('packaging/network.ur.urnetwork.desktop',
+install_data('packaging/com.bringyour.network.desktop',
install_dir : get_option('datadir') / 'applications')
# AppStream metadata — required by Flathub, read by GNOME Software / Discover
# for the .deb and .rpm too. The component id matches the .desktop basename.
-install_data('packaging/network.ur.urnetwork.metainfo.xml',
+install_data('packaging/com.bringyour.network.metainfo.xml',
install_dir : get_option('datadir') / 'metainfo')
-# hicolor icons (Icon=urnetwork in the desktop file)
-install_data('packaging/icons/hicolor/48x48/apps/urnetwork.png',
+# hicolor icons (Icon=com.bringyour.network in the desktop file)
+install_data('packaging/icons/hicolor/48x48/apps/com.bringyour.network.png',
install_dir : get_option('datadir') / 'icons' / 'hicolor' / '48x48' / 'apps')
-install_data('packaging/icons/hicolor/256x256/apps/urnetwork.png',
+install_data('packaging/icons/hicolor/256x256/apps/com.bringyour.network.png',
install_dir : get_option('datadir') / 'icons' / 'hicolor' / '256x256' / 'apps')
# autostart INERT TEMPLATE: root-owned under /etc/urnetwork/autostart/, where
# no desktop reads it; the GUI symlinks it into ~/.config/autostart atomically
if host_integration
-install_data('packaging/autostart/network.ur.urnetwork.desktop',
+install_data('packaging/autostart/com.bringyour.network.desktop',
install_dir : '/etc/urnetwork/autostart')
endif
diff --git a/app/packaging/autostart/network.ur.urnetwork.desktop b/app/packaging/autostart/com.bringyour.network.desktop
similarity index 96%
rename from app/packaging/autostart/network.ur.urnetwork.desktop
rename to app/packaging/autostart/com.bringyour.network.desktop
index ac7d01f..31b99cd 100644
--- a/app/packaging/autostart/network.ur.urnetwork.desktop
+++ b/app/packaging/autostart/com.bringyour.network.desktop
@@ -2,7 +2,7 @@
Type=Application
Name=URnetwork
Comment=URnetwork VPN
-Icon=urnetwork
+Icon=com.bringyour.network
Exec=urnetwork
# TryExec self-disables this entry once the daemon package (and with it the
# launcher) is removed, so a stale symlink cannot error at every login.
diff --git a/app/packaging/network.ur.urnetwork.desktop b/app/packaging/com.bringyour.network.desktop
similarity index 93%
rename from app/packaging/network.ur.urnetwork.desktop
rename to app/packaging/com.bringyour.network.desktop
index 63e8327..e592abc 100644
--- a/app/packaging/network.ur.urnetwork.desktop
+++ b/app/packaging/com.bringyour.network.desktop
@@ -2,7 +2,7 @@
Type=Application
Name=URnetwork
Comment=URnetwork VPN
-Icon=urnetwork
+Icon=com.bringyour.network
# /usr/bin/urnetwork is the launcher script the daemon package installs; it
# finds the user's AppImage (search order in MIGRATION.md) and execs it with
# the same arguments. Keeping Exec= on a stable, package-managed path is what
@@ -13,7 +13,7 @@ Terminal=false
Categories=Network;Security;
Keywords=VPN;URnetwork;proxy;privacy;
StartupNotify=true
-StartupWMClass=network.ur.urnetwork
+StartupWMClass=com.bringyour.network
# Register the urnetwork:// scheme so OAuth/SSO callbacks and wallet deep
# links route back to the app. Shipped as a plain dpkg file so the
# desktop-file-utils dpkg trigger refreshes mimeinfo.cache automatically
diff --git a/app/packaging/network.ur.urnetwork.metainfo.xml b/app/packaging/com.bringyour.network.metainfo.xml
similarity index 93%
rename from app/packaging/network.ur.urnetwork.metainfo.xml
rename to app/packaging/com.bringyour.network.metainfo.xml
index 409e1e0..6770f1e 100644
--- a/app/packaging/network.ur.urnetwork.metainfo.xml
+++ b/app/packaging/com.bringyour.network.metainfo.xml
@@ -2,7 +2,7 @@
- network.ur.urnetwork
+ com.bringyour.network
URnetwork
Private, decentralized VPN powered by a peer network
@@ -45,7 +45,7 @@
- network.ur.urnetwork.desktop
+ com.bringyour.network.desktop
https://ur.io
https://github.com/urnetwork/urnetwork-linux/issues
diff --git a/app/packaging/icons/hicolor/256x256/apps/urnetwork.png b/app/packaging/icons/hicolor/256x256/apps/com.bringyour.network.png
similarity index 100%
rename from app/packaging/icons/hicolor/256x256/apps/urnetwork.png
rename to app/packaging/icons/hicolor/256x256/apps/com.bringyour.network.png
diff --git a/app/packaging/icons/hicolor/48x48/apps/urnetwork.png b/app/packaging/icons/hicolor/48x48/apps/com.bringyour.network.png
similarity index 100%
rename from app/packaging/icons/hicolor/48x48/apps/urnetwork.png
rename to app/packaging/icons/hicolor/48x48/apps/com.bringyour.network.png
diff --git a/app/snap/snapcraft.yaml b/app/snap/snapcraft.yaml
index 43a049f..ced14b0 100644
--- a/app/snap/snapcraft.yaml
+++ b/app/snap/snapcraft.yaml
@@ -49,7 +49,7 @@ apps:
urnetwork:
command: usr/bin/urnetwork
extensions: [gnome] # GTK4/libadwaita runtime, themes, portals, wayland/x11
- common-id: network.ur.urnetwork
+ common-id: com.bringyour.network
desktop: usr/share/applications/urnetwork.desktop
plugs:
- network # outbound sockets (SDK platform + provider traffic)
diff --git a/app/src/ControlProtocol.hpp b/app/src/ControlProtocol.hpp
index 078d762..985f566 100644
--- a/app/src/ControlProtocol.hpp
+++ b/app/src/ControlProtocol.hpp
@@ -108,14 +108,14 @@ inline constexpr const char* kControlGroupName = "urnetwork";
// policy file absent -> byte-for-byte today's behaviour: 0660
// root:urnetwork and AuthorizeControlPeer.
inline constexpr const char* kPolkitPolicyPath =
- "/usr/share/polkit-1/actions/network.ur.urnetwork.policy";
+ "/usr/share/polkit-1/actions/com.bringyour.network.policy";
// The immutable-host twin. ostree/bootc machines mount /usr read-only, so the
// tarball installer maps its whole payload under /usr/local — and polkit has
// read both directories since 124. ControlServer::PolicyPath() probes this one
// FIRST; without it the daemon looks in the one place the file cannot be on
// Bazzite/Silverblue/Kinoite/SteamOS and silently falls back to the group.
inline constexpr const char* kPolkitPolicyPathLocal =
- "/usr/local/share/polkit-1/actions/network.ur.urnetwork.policy";
+ "/usr/local/share/polkit-1/actions/com.bringyour.network.policy";
// HelloReply::auth_mode — which authority this daemon actually latched at
// start. The GUI needs it because the "add yourself to the urnetwork group,
@@ -125,16 +125,16 @@ inline constexpr const char* kAuthModePolkit = "polkit";
inline constexpr const char* kAuthModeGroup = "group";
// The four polkit action ids, namespaced to the app id. Shipped in
-// packaging/polkit/network.ur.urnetwork.policy (0644 root:root — polkit
+// packaging/polkit/com.bringyour.network.policy (0644 root:root — polkit
// ignores group- or world-writable action files). They are checked by
// urnetworkd, never by the GUI: the subject is built from SO_PEERCRED on the
// connection being served, so a client can neither nominate its own subject
// nor skip the check by not asking.
-inline constexpr const char* kActionControlTunnel = "network.ur.urnetwork.control-tunnel";
+inline constexpr const char* kActionControlTunnel = "com.bringyour.network.control-tunnel";
inline constexpr const char* kActionManageKillSwitch =
- "network.ur.urnetwork.manage-kill-switch";
-inline constexpr const char* kActionTakeOverTunnel = "network.ur.urnetwork.take-over-tunnel";
-inline constexpr const char* kActionReadLog = "network.ur.urnetwork.read-log";
+ "com.bringyour.network.manage-kill-switch";
+inline constexpr const char* kActionTakeOverTunnel = "com.bringyour.network.take-over-tunnel";
+inline constexpr const char* kActionReadLog = "com.bringyour.network.read-log";
// The SDK's built-in default device-RPC address (sdk/device_rpc.go:109,
// deviceRpcDefaultAddress = "127.0.0.1:12025"). Kept as a NAMED CONSTANT ONLY,
diff --git a/app/src/MainWindow.cpp b/app/src/MainWindow.cpp
index 22e496e..9a424b6 100644
--- a/app/src/MainWindow.cpp
+++ b/app/src/MainWindow.cpp
@@ -517,7 +517,7 @@ void MainWindow::BuildChrome() {
if (auto logo = BrandLogoTexture()) {
icon->set(logo);
} else {
- icon->set_from_icon_name("urnetwork");
+ icon->set_from_icon_name(kAppIconName);
}
icon->set_pixel_size(20);
brand->append(*icon);
diff --git a/app/src/SecretServiceRpcSessionStore.cpp b/app/src/SecretServiceRpcSessionStore.cpp
index 325fe40..664286a 100644
--- a/app/src/SecretServiceRpcSessionStore.cpp
+++ b/app/src/SecretServiceRpcSessionStore.cpp
@@ -12,7 +12,13 @@ namespace {
constexpr int kSecretPayloadVersion = 1;
constexpr size_t kMaxSecretPayloadBytes = 256 * 1024;
-constexpr const char* kApplicationAttribute = "network.ur.urnetwork";
+// The keyring attribute that scopes our secrets. Renamed with the app ID, so
+// an entry written by an older build is not found by this one and the app
+// falls back to a fresh RPC session — the same one-time cost as the Flatpak
+// data path moving. Deliberately NOT dual-read: keeping the old attribute
+// alive would leave the previous app identity holding live key material in the
+// user's keyring with nothing left to clean it up.
+constexpr const char* kApplicationAttribute = "com.bringyour.network";
const SecretSchema kRpcSessionSchema = [] {
// Zero-initialize libsecret's reserved ABI fields explicitly. A short C
diff --git a/app/src/UrTheme.cpp b/app/src/UrTheme.cpp
index db6d024..44573d0 100644
--- a/app/src/UrTheme.cpp
+++ b/app/src/UrTheme.cpp
@@ -369,7 +369,8 @@ Glib::RefPtr BrandLogoTexture() {
const std::string dir = BrandIconDir();
if (dir.empty()) return cached;
for (const char* size : {"256x256", "48x48"}) {
- const std::string path = dir + "/hicolor/" + size + "/apps/urnetwork.png";
+ const std::string path =
+ dir + "/hicolor/" + size + "/apps/" + kAppIconName + ".png";
if (!g_file_test(path.c_str(), G_FILE_TEST_IS_REGULAR)) continue;
try {
cached = Gdk::Texture::create_from_filename(path);
@@ -378,7 +379,7 @@ Glib::RefPtr BrandLogoTexture() {
g_warning("brand logo: %s failed to load: %s", path.c_str(), e.what());
}
}
- g_warning("brand logo: no urnetwork.png under %s", dir.c_str());
+ g_warning("brand logo: no %s.png under %s", kAppIconName, dir.c_str());
return cached;
}
diff --git a/app/src/UrTheme.hpp b/app/src/UrTheme.hpp
index 7341792..de5e00f 100644
--- a/app/src/UrTheme.hpp
+++ b/app/src/UrTheme.hpp
@@ -34,10 +34,20 @@ void LoadBrandFonts();
// Install the brand CSS provider once per display. Safe to call repeatedly.
void EnsureBrandCss();
+// The app icon's basename AND its icon-theme name: the packaging installs
+// /icons/hicolor//apps/.png, and the .desktop's
+// Icon= key names the same string. It lives here as ONE constant because it was
+// previously spelled as a literal in two places -- the by-path load in
+// UrTheme.cpp and the by-name fallback in MainWindow.cpp -- and the rename to
+// the reverse-DNS app id caught only the packaging, so both lookups missed and
+// the title-bar logo went blank with no error. Flatpak additionally REQUIRES
+// this to equal the app id, or it exports no icon at all.
+inline constexpr const char* kAppIconName = "com.bringyour.network";
+
// Register the app's hicolor icon directory with the default icon theme so
-// the icon NAME "urnetwork" resolves (window icon, tray, .desktop matching)
+// the icon NAME "com.bringyour.network" resolves (window icon, tray, .desktop matching)
// even when the app runs from a build tree or a relocated AppImage. Without
-// this, set_from_icon_name("urnetwork") silently renders a BLANK image —
+// this, set_from_icon_name("com.bringyour.network") silently renders a BLANK image —
// which is exactly how the title-bar logo came out empty. Idempotent.
void RegisterBrandIcons();
diff --git a/app/src/main.cpp b/app/src/main.cpp
index 6eb543f..dc33838 100644
--- a/app/src/main.cpp
+++ b/app/src/main.cpp
@@ -78,7 +78,7 @@ int main(int argc, char** argv) {
// the window with the app (and the hide-to-tray window keeps its identity).
// HANDLES_OPEN: the single instance receives urnetwork:// deep links (wallet
// callbacks) via signal_open — the .desktop registers x-scheme-handler/urnetwork.
- auto app = Gtk::Application::create("network.ur.urnetwork",
+ auto app = Gtk::Application::create("com.bringyour.network",
Gio::Application::Flags::HANDLES_OPEN);
// Hold the application so it survives with only the tray (window hidden).
@@ -98,7 +98,7 @@ int main(int argc, char** argv) {
// missing face fails silently to the fallback font (windows parity).
urnw::LoadBrandFonts();
urnw::EnsureBrandCss();
- // the icon NAME "urnetwork" must resolve for the window icon and the
+ // the icon NAME kAppIconName must resolve for the window icon and the
// tray, wherever the app runs from
urnw::RegisterBrandIcons();
diff --git a/docs/DISTRO-SUPPORT.md b/docs/DISTRO-SUPPORT.md
index 019940c..f4bec27 100644
--- a/docs/DISTRO-SUPPORT.md
+++ b/docs/DISTRO-SUPPORT.md
@@ -344,8 +344,8 @@ the kill switch is available), the `.deb` exists, and the daemon's floor is exac
| | Arch (rolling) |
|---|---|
-| Channel today | **T** (tarball) + **A** |
-| Channel it should be | AUR `PKGBUILD` producing a native package |
+| Channel today | **P** (`.pkg.tar.zst`, `packaging/make-arch.sh`, nfpm `-p archlinux`) + **T** (tarball) + **A** |
+| Channel it should be | as today. An AUR `PKGBUILD` would be a *fourth* independent copy of the installed-path table; the first-party binary package is built from the same `assemble_daemon_root()` tree as the `.deb` and `.rpm`, so it cannot drift from them. An AUR recipe remains reasonable as a discovery channel, not as a replacement. |
| init | systemd **[I]** |
| LSM | none by default **[I]** — the SELinux branch no-ops (`getenforce` absent) **[M-src]** |
| Firewall | nftables available, nothing enabled by default **[I]** |
diff --git a/docs/TESTING-CACHYOS.md b/docs/TESTING-CACHYOS.md
index 3cc0da3..5450d01 100644
--- a/docs/TESTING-CACHYOS.md
+++ b/docs/TESTING-CACHYOS.md
@@ -1,9 +1,25 @@
# Testing URnetwork on CachyOS — the tester's procedure
**Target:** CachyOS (Arch-based), x86_64, systemd, cgroup v2, a desktop session.
-**Channels covered:** the daemon **install tarball**, the GUI **AppImage**, the GUI **Flatpak**.
-**Not covered:** `.deb` and `.rpm` — Arch has neither. On this distro the tarball is the
-daemon's only channel, so if the tarball is broken there is no fallback.
+**Channels covered:** the daemon **pacman package** (`.pkg.tar.zst`) and the daemon
+**install tarball**, the GUI **AppImage**, the GUI **Flatpak**.
+**Not covered:** `.deb` and `.rpm` — Arch has neither.
+
+> **THE TARBALL IS NO LONGER THE ONLY DAEMON CHANNEL HERE.** Releases now carry
+> `urnetwork-daemon--x86_64.pkg.tar.zst`, built by `packaging/make-arch.sh`
+> from the same staging tree as the `.deb` and the `.rpm`, so pacman owns and tracks
+> the files. Prefer it: it declares `nftables` and `fuse2` (a minimal Arch install has
+> neither, and the tarball can only complain about them after the fact), it keeps
+> `/etc` files as `.pacnew`/`.pacsave`, and `pacman -R` removes it cleanly.
+>
+> **Never install both on one machine.** Each channel refuses to overwrite the other's
+> files, on purpose: two owners of the same paths means the next `pacman -Syu` would
+> half-replace the install. If you already ran the tarball installer, run
+> `sudo /usr/lib/urnetwork/uninstall.sh` before `pacman -U`.
+>
+> If a release you are testing has no `.pkg.tar.zst` (the package is still optional in
+> the pipeline while it settles), fall back to the tarball and everything below still
+> applies.
---
@@ -356,7 +372,8 @@ Repo: `Ryanmello07/urnetwork-linux`. Current beta at time of writing:
| Asset | You need it for |
|---|---|
-| `urnetwork-daemon--amd64.install.tar.gz` | **The daemon. The only daemon channel on Arch.** |
+| `urnetwork-daemon--x86_64.pkg.tar.zst` | **The daemon, native pacman package — prefer this.** `sudo pacman -U ./`. May be absent from older builds. |
+| `urnetwork-daemon--amd64.install.tar.gz` | **The daemon, portable channel.** The fallback when the release has no pacman package, and the right choice on SteamOS (read-only `/usr`). |
| `URnetwork--amd64.AppImage` | The GUI, AppImage channel. |
| `URnetwork--amd64.AppImage.zsync` | Update control file. Attached for mirroring only — GitHub Releases answers zsync's multi-range requests with HTTP 501, so the in-app updater uses the self-hosted copy. You do not need it to test. |
| `urnetwork-daemon__amd64.deb` | **Ignore.** No use on Arch. |
@@ -1094,7 +1111,7 @@ cd urnetwork-linux
### 10.2 Run it
```bash
-flatpak run network.ur.urnetwork
+flatpak run com.bringyour.network
```
### 10.3 What is different from the AppImage — and what to test because of it
@@ -1121,7 +1138,7 @@ inode, and every `connect()` fails with `EACCES` — which historically got repo
as a group-membership problem. The app now detects this and says so instead. To test it:
```bash
-flatpak run network.ur.urnetwork & # leave it running
+flatpak run com.bringyour.network & # leave it running
sudo systemctl restart urnetworkd
# now press Connect in the Flatpak GUI
```
@@ -1187,7 +1204,7 @@ Plus, separately:
* **The full `--selftest-egress` output and its exit code** (§5.2). Always.
* **The app's own log** — the `connect:` lines. From a terminal launch that is `gui.log` from
§7.1; otherwise `journalctl --user -b | grep -E 'urnetwork|connect:'`, or for the Flatpak,
- `flatpak run network.ur.urnetwork 2>&1 | tee flatpak-gui.log`.
+ `flatpak run com.bringyour.network 2>&1 | tee flatpak-gui.log`.
* **The installer's full output** (§4 dry-run and §6 real run).
**Use `stat -c %C`, never `ls -Z`.** On a machine with no SELinux, `ls -Z` prints `?` and
@@ -1208,7 +1225,7 @@ tunnel addresses (`169.254.x.x`) are fine and are useful to us.
sudo systemctl stop urnetworkd
sudo /usr/lib/urnetwork/uninstall.sh # add --purge to also remove state + group
rm -f ~/.local/lib/urnetwork/URnetwork.AppImage
-flatpak uninstall --user network.ur.urnetwork
+flatpak uninstall --user com.bringyour.network
```
Then confirm the machine is back to normal:
diff --git a/docs/linux_agent_help.md b/docs/linux_agent_help.md
index 5606c9d..4508e4a 100644
--- a/docs/linux_agent_help.md
+++ b/docs/linux_agent_help.md
@@ -1803,7 +1803,7 @@ single-process model existed only under Snap and is dead). ~17.8k lines of C++ a
`urnetwork_sdk.hpp` wrapper as Windows. Holds `urnet::DeviceRemote` + a control-socket
client. Ships as a **user AppImage in a user-writable path** with a `/usr/bin/urnetwork`
launcher-script indirection; never installed by a package (meson `install_rpath
- '$ORIGIN/../lib'`). App id `network.ur.urnetwork`; GApplication HANDLES_OPEN deep links;
+ '$ORIGIN/../lib'`). App id `com.bringyour.network`; GApplication HANDLES_OPEN deep links;
hide-to-tray; forces `ADW_COLOR_SCHEME_FORCE_DARK`; runtime-resolved `bindtextdomain`.
- **`urnetworkd`** — root daemon, **no GTK of any kind** (glib GMainLoop + gio + json + SDK;
`-static-libstdc++ -static-libgcc`). Holds `DeviceLocal(enable_rpc=true)`, opens
@@ -1898,8 +1898,8 @@ replacing it, keeping its version/SDK-match enforcement.
`urnetworkd.service` (Type=notify, After/Wants `network-pre.target`,
`RuntimeDirectory=urnetwork` 0750, `StateDirectory`/`LogsDirectory` 0700,
`NoNewPrivileges`, `ProtectHome`, `Restart=on-failure`/`RestartSec=2`),
- `network.ur.urnetwork.desktop` (`Exec=urnetwork %u`,
- `MimeType=x-scheme-handler/urnetwork;`, `StartupWMClass=network.ur.urnetwork`),
+ `com.bringyour.network.desktop` (`Exec=urnetwork %u`,
+ `MimeType=x-scheme-handler/urnetwork;`, `StartupWMClass=com.bringyour.network`),
`urnetwork-launcher` (installed `/usr/bin/urnetwork`; search order `$URNETWORK_APPIMAGE` →
`~/.local/lib/urnetwork/URnetwork.AppImage` → `~/Applications/URnetwork*.AppImage` newest →
`/usr/lib/urnetwork/URnetwork.AppImage` → `urnetwork-gui` on PATH; exit 127 with install
@@ -1947,7 +1947,7 @@ Files that ARE snap support (delete):
- `linux:app/snap/snapcraft.yaml` — the whole file (114 lines). Already carries a SUPERSEDED
banner ("NOT BUILT, NOT SHIPPED... describes the SINGLE-PROCESS architecture... Do not
resurrect as-is"); it is also **broken** — it organizes `packaging/urnetwork.desktop`,
- which was renamed to `network.ur.urnetwork.desktop`, and its `version: "0.0.0"` sed
+ which was renamed to `com.bringyour.network.desktop`, and its `version: "0.0.0"` sed
stamping mechanism is gone. It was retained only because "Snap Store as secondary channel"
was left open (`APPIMAGE.md` §8 item 5); that door is now closed.
- `linux:app/.gitignore` lines 8–11: `*.snap`, `parts/`, `prime/`, `stage/`.
@@ -2139,7 +2139,7 @@ EOL ~yearly — plan an annual runtime bump.
- polkit: actions as XML `.policy` in `/usr/share/polkit-1/actions/`; the daemon checks each
privileged request via `CheckAuthorization` (CLI form `pkcheck`), passing request variables
so admins can write rules. Proposed action split (tiering idiom is standard; names are our
- proposal): `network.ur.urnetwork.connect`/`.disconnect` → `allow_active=yes`;
+ proposal): `com.bringyour.network.connect`/`.disconnect` → `allow_active=yes`;
`.install-daemon`, `.set-killswitch`, `.clear-blocking` → `auth_admin_keep`. Simpler
alternative used by Mullvad/Tailscale: socket file mode + group membership (already the
linux: model — group `urnetwork`); reasonable default, with polkit/pkexec reserved for the
diff --git a/packaging/arch/nfpm.yaml b/packaging/arch/nfpm.yaml
new file mode 100644
index 0000000..7181686
--- /dev/null
+++ b/packaging/arch/nfpm.yaml
@@ -0,0 +1,360 @@
+# nfpm (https://nfpm.goreleaser.com) package definition for the URnetwork
+# daemon .pkg.tar.zst -- the Arch-family native channel (Arch, CachyOS,
+# EndeavourOS, Manjaro), and the third wrapper around the ONE staging tree
+# packaging/lib/common.sh assemble_daemon_root() produces.
+#
+# WHY nfpm AND NOT A PKGBUILD
+# ---------------------------
+# The .deb and the .rpm are already nfpm, over the same assemble_daemon_root()
+# output, and that shared function is the only reason "the three daemon
+# packages ship the same payload" is a fact rather than a hope. A PKGBUILD
+# would be a fourth, independent copy of the installed-path table -- exactly
+# what packaging/rpm/nfpm.yaml's header refuses for rpmbuild, and for the same
+# reason. nfpm 2.47.0 (the UR_NFPM_VERSION pin in
+# .github/workflows/beta-build.yml) lists archlinux among its packagers, and
+# its output is a real .pkg.tar.zst with .PKGINFO, .MTREE and .INSTALL that
+# pacman installs, upgrades and removes.
+#
+# It also means this package needs no Arch machine to build: nfpm is pure Go,
+# its zstd is pure Go, and the CI daemon job builds this inside the same
+# ubuntu:22.04 container that produces the .deb and the .rpm.
+#
+# WHAT THAT COSTS, STATED PLAINLY
+# -------------------------------
+# * There is no makepkg, no namcap and no PKGBUILD to submit to the AUR.
+# This is a first-party binary package for direct `pacman -U`, not an AUR
+# submission. An AUR recipe would be a separate, later piece of work.
+# * nfpm's archlinux packager has NO optdepends field, and it SILENTLY
+# DISCARDS the shared `recommends:`/`suggests:` keys -- measured on 2.47.0:
+# a config carrying `recommends: [polkit]` emitted `Recommends:` in the
+# .deb and NOTHING in the .PKGINFO, with no warning. Do not add a
+# `recommends:` block here expecting an advisory line; it produces no
+# bytes. (It would buy little anyway: PKGBUILD(5) is explicit that
+# "optdepends are ... for informational purposes only and are not utilized
+# by pacman during dependency resolution" -- there is no Arch analogue of
+# APT::Install-Recommends or dnf's install_weak_deps.) The polkit story is
+# told by scripts/post_install instead, which is strictly better than a
+# metadata line nobody reads.
+# * The dependency STRINGS are passed through verbatim and unvalidated.
+# Debian alternation ("libfuse2t64 | libfuse2") and Debian version syntax
+# ("polkit (>= 124)") emit as literal `depend =` tokens that pacman then
+# cannot resolve -- an UNINSTALLABLE package that builds without a
+# complaint. The depends list below is therefore REWRITTEN for Arch, never
+# copied from packaging/deb/nfpm.yaml. Bare names, or `pkg>=ver`, only.
+#
+# Do not run nfpm against this file directly: this is a TEMPLATE, exactly like
+# its two siblings. make-arch.sh assembles the package root, relocates the
+# systemd unit out of /lib, substitutes ${PKG_VERSION} / ${PKG_RELEASE} /
+# ${PKG_ARCH} / ${PKG_MTIME} / ${NFPM_ARCH} / ${PKGROOT} itself (nfpm does not
+# expand environment variables in contents[].src -- verified for the .deb
+# against nfpm v2.47), and names the output per the asset contract.
+name: urnetwork-daemon
+# The Debian arch spelling on the way in; nfpm maps amd64 -> x86_64 and
+# arm64 -> aarch64 for the archlinux packager (measured: `arch: amd64` emitted
+# `arch = x86_64` in .PKGINFO). The archlinux: block below states the pacman
+# spelling explicitly all the same, so make-arch.sh's filename and this
+# package's own metadata come from ONE variable and cannot disagree.
+arch: "${NFPM_ARCH}"
+platform: linux
+# ONE TIMESTAMP FOR EVERY FILE, AND IT IS NOT COSMETIC.
+#
+# nfpm writes two independent time fields: the tar header mtime of each member,
+# and .MTREE's `time=` for the same path. Left to itself it takes the tar mtime
+# from the moment it writes the archive and the .MTREE time from the SOURCE
+# file's ModTime -- which make-arch.sh created seconds earlier, when
+# assemble_daemon_root copied it. pacman sets each installed file's mtime from
+# the TAR header and then checks it against the .MTREE copy in its local
+# database, so the two disagreeing by even one second makes
+#
+# pacman -Qkk urnetwork-daemon
+# -> 144 total files, 24 altered files ("Modification time mismatch")
+#
+# FOREVER, on every installed machine. Measured exactly that way on real pacman
+# before this line existed -- and measured as CLEAN in a build where the copy
+# and the archive write happened to land in the same second, which is what
+# makes it a nasty one: it is intermittent, and the clean build is the lucky
+# one. For a VPN daemon `pacman -Qkk` is precisely the check a careful user
+# runs, and "24 altered files" on a package they just installed is the wrong
+# answer to give them.
+#
+# make-arch.sh substitutes one value here (SOURCE_DATE_EPOCH when set, so the
+# package participates in reproducible builds; otherwise the build time) and
+# asserts afterwards that every .MTREE entry carries it.
+mtime: "${PKG_MTIME}"
+# THE VERSION FOLD, AND WHY IT IS A THIRD DIFFERENT ANSWER.
+#
+# The pipeline's VERSION is "2026.8.20-1024376890-beta". Each packager mangles
+# it differently and no two of the three scripts here derive their version
+# fields the same way:
+# .deb splits at the LAST hyphen -> Version: 2026.8.20-1024376890-beta
+# .rpm splits at the FIRST hyphen -> 2026.8.20 + 0.1024376890.beta
+# .pkg splits at NEITHER: pacman's pkgver may not contain '-' at all (it is
+# the pkgver/pkgrel separator), so make-arch.sh folds the WHOLE string
+# to dots and pins pkgrel to the literal 1.
+# pkgver = 2026.8.20.1024376890.beta pkgrel = 1
+#
+# Both other shapes were measured and both are wrong:
+# * rpm-style split (version=2026.8.20, release="1024376890.beta") -> nfpm
+# coerces a non-integer release to 1 SILENTLY and emits
+# `pkgver = 2026.8.20-1`. The build id and the beta marker are simply gone,
+# every build of a given day becomes the same pacman version, and
+# `pacman -U` of a newer build reports "is up to date -- reinstalling".
+# That is the tarball's problem back again, wearing a pacman badge.
+# * the raw VERSION -> `pkgver = 2026.8.20-1024376890-beta-1`, which nfpm
+# writes happily and pacman then REJECTS outright: "package version
+# contains invalid characters" / "invalid or corrupted package".
+# alpm compares dot-separated segments numerically, so the folded form orders
+# correctly across both the day and the month rollover (verified with vercmp:
+# 2026.8.9.x < 2026.8.20.y < 2026.9.1.z).
+version: "${PKG_VERSION}"
+release: "${PKG_RELEASE}"
+# version_schema none for the same reason packaging/rpm/nfpm.yaml carries it:
+# keep make-arch.sh's fold verbatim. Left at nfpm's semver default the filename
+# and the .PKGINFO DISAGREE -- measured: file "...2026.8.161020679030_beta-1..."
+# with `pkgver = 2026.8.16-1` inside it.
+version_schema: none
+maintainer: "URnetwork "
+vendor: "URnetwork"
+homepage: "https://ur.io"
+# Ground rule 8: the code is MPL-2.0, used verbatim in the deb License:, the
+# rpm's license: and the Flathub project_license.
+license: "MPL-2.0"
+description: |
+ Privileged system half of URnetwork for Linux: owns /dev/net/tun, routes and
+ DNS, exposes a control socket under /run/urnetwork whose privileged verbs are
+ gated by polkit (falling back to the urnetwork group where polkit is absent),
+ and starts idle -- no tunnel comes up until an authorized desktop client
+ asks. The GUI is the separate, unprivileged URnetwork AppImage, which lives
+ in a user-writable path so it can self-update; this package installs the
+ stable /usr/bin/urnetwork launcher plus the desktop entry, urnetwork://
+ scheme handler, icons and autostart template that point at it.
+
+# NO GTK (or any GUI) dependency -- the daemon must install on a headless box.
+depends:
+ # C runtime floor. The SDK is cross-built against glibc 2.35 (jammy),
+ # sdk/cgo/Makefile target gnu.2.35, and the .deb and .rpm both declare that
+ # floor by hand because nfpm runs no dependency generator.
+ #
+ # ON A ROLLING DISTRIBUTION THIS LINE IS ALWAYS SATISFIED (Arch is on 2.4x),
+ # and it is kept anyway so all three package definitions state the same floor
+ # in the same place. app/tests/glibc-floor-gate.sh (meson test `glibc-floor`)
+ # is what couples the declaration to the actual binary.
+ - "glibc>=2.35"
+ # Tunnel.cpp builds the tun link, the capture routes and the policy rules
+ # with `ip`, and the kill switch / egress self-exclusion with `nft`. BOTH are
+ # marked required=true in the daemon's own preflight (daemon/main.cpp), so
+ # without either the package installs, the service starts, and the first
+ # Connect fails at the first exec.
+ #
+ # iproute2 is in Arch's `base` meta-package and nftables is NOT -- so the
+ # nftables line is the one that does real work here. It is also the exact gap
+ # the .deb shipped with for a while (declared by the rpm, missing from the
+ # deb), which is what a declared dependency exists to prevent.
+ #
+ # Arch spells it iproute2, like Debian and openSUSE; only Fedora says
+ # `iproute`. Do not copy the rpm's name.
+ - "iproute2"
+ - "nftables"
+ # groupadd, used by scripts/pre_install. Arch's `shadow` is Fedora's
+ # `shadow-utils`; there is no `addgroup` on Arch at all, which is why the
+ # Debian postinst's addgroup-first shape is not reproduced in this package.
+ - "shadow"
+ # The unit, systemctl, and everything scripts/{post_install,post_upgrade,
+ # pre_remove} drive. Also what provides the alpm hooks in
+ # /usr/share/libalpm/hooks that daemon-reload on any change under
+ # usr/lib/systemd/system.
+ - "systemd"
+ # THE ONE GENUINE WIN OF SHIPPING A NATIVE PACKAGE (APPIMAGE.md 11d): the GUI
+ # AppImage needs libfuse.so.2 at runtime and every AppImage-only vendor walks
+ # its users through installing it by hand. This package ships
+ # /usr/bin/urnetwork and the .desktop entry that launch that AppImage, so an
+ # Arch box without fuse2 gets a menu entry that dies on a missing library.
+ #
+ # HARD, matching the .deb (which makes libfuse2 a Depends) rather than the
+ # .rpm (which makes libfuse.so.2 a Recommends). That difference is forced,
+ # not chosen: dnf and zypper install weak dependencies by default, so the rpm
+ # gets the same practical outcome from a Recommends -- pacman has no weak
+ # dependencies at all, so on Arch the choice is "hard" or "silently absent".
+ # fuse2 is 456 KB and pulls only fuse-common and glibc; nothing in a base
+ # Arch install provides it (verified against the archlinux image: absent).
+ - "fuse2"
+# POLKIT IS DELIBERATELY NOT DECLARED, and this is the one place the three
+# packages genuinely diverge. State the reasoning so nobody "fixes" it:
+#
+# * The .deb declares it as a RECOMMENDS with an explicit rationale -- the
+# package must still install on a headless box, and apt installs Recommends
+# by default so a desktop gets it anyway.
+# * The .rpm declares it NOWHERE, in depends or recommends. So "no polkit
+# line" is already the established behaviour of one of the two native
+# packages, and this one matches it.
+# * pacman has no weak-dependency mechanism to express the .deb's actual
+# intent, and nfpm cannot emit optdepends even for the advisory line
+# (see the header). The only two options are a HARD depend or nothing.
+# * A hard depend would drag polkit and its pam/systemd/glib2/duktape/expat
+# chain (~1.9 MB) onto every headless Arch install to buy nothing there,
+# and would contradict the headless rationale the .deb states in its own
+# words.
+# * It is SAFE to omit because the fallback is real and fails open, not shut:
+# ControlServer::PolkitPolicyPresent() requires BOTH the action file AND
+# PolkitRuntimePresent() (which probes /usr/bin/pkcheck and
+# /usr/lib/polkit-1/polkitd -- Arch's layout is covered), so an Arch box
+# with no polkit latches Group mode and authorizes uid 0 plus the
+# `urnetwork` group, exactly as it always did. A machine that shipped the
+# action file with no polkit runtime is precisely the fail-shut trap that
+# two-fact check exists to prevent.
+# * On any Arch DESKTOP -- which is every machine that will run the GUI --
+# polkit is already present through GNOME/KDE/Cinnamon, so the polkit path
+# is taken and there is no group-then-log-out first run. Only a minimal
+# Arch install gets the group fallback, which is the same honest outcome
+# the .rpm already ships to a minimal Fedora.
+#
+# scripts/post_install detects which of the two is in force and says so.
+
+contents:
+ # ONE /usr tree, and NOTHING under /lib. Two Arch facts drive this, and the
+ # first one is a hard install failure rather than a style preference:
+ #
+ # 1. /lib IS A SYMLINK TO usr/lib, OWNED BY THE `filesystem` PACKAGE. A
+ # package that carries any member under lib/ aborts the transaction:
+ # error: failed to commit transaction (conflicting files)
+ # urnetwork-daemon: /lib exists in filesystem (owned by filesystem)
+ # and nothing is installed. There is no YAML-level dodge -- nfpm emits an
+ # implicit parent-directory member for every dst, so declaring the unit as
+ # a single file at /lib/systemd/system/urnetworkd.service produces the
+ # lib/ member just the same. make-arch.sh RELOCATES the unit in ${PKGROOT}
+ # before nfpm runs, exactly as make-rpm.sh does, and asserts that nothing
+ # else was staged under /lib.
+ #
+ # 2. usr/lib/systemd/system IS THE PATH ARCH'S OWN HOOKS WATCH.
+ # /usr/share/libalpm/hooks/30-systemd-daemon-reload-system.hook targets
+ # `usr/lib/systemd/system/*`; a unit shipped anywhere else would not fire
+ # it. (Those hooks are PostTransaction, i.e. AFTER post_install and
+ # post_upgrade, which is why the scripts still run their own
+ # `systemctl daemon-reload` before starting anything -- the hook is not a
+ # substitute for the reload in the scriptlet.)
+ # The unit is NOT marked config: /usr/lib/systemd/system is the vendor
+ # unit directory, admin overrides live in /etc/systemd/system (drop-ins,
+ # `systemctl edit`), and pacman never writes there -- so "never overwrite
+ # an admin-edited unit" is satisfied by the path, with no scriptlet logic
+ # that could get it wrong.
+ #
+ # A GLOB, NOT `type: tree`, AND THAT IS NOT COSMETIC. With `type: tree` nfpm
+ # writes Go's unmasked fs.FileMode for every directory it walks into .MTREE
+ # -- `mode=20000000755`, i.e. fs.ModeDir|0755 formatted with %o and never
+ # masked to permission bits. Measured on the pristine nfpm output: 83 such
+ # entries, one `warning: directory permissions differ on / filesystem:
+ # 755 package: 755` per directory at install time (both sides printing 755
+ # is the tell), and `pacman -Qkk urnetwork-daemon` reporting those
+ # directories as altered forever. For a VPN daemon that is exactly the check
+ # a security-minded user runs. The glob form emits the same 140 members with
+ # byte-identical per-file modes and zero folded entries -- verified by
+ # diffing `tar tvf` of both packages (mode, size and path identical; only
+ # .MTREE differs).
+ #
+ # DO NOT "fix" the directory modes with `file_info: {mode: 0755}` on a tree
+ # entry instead: that silences the warnings by clobbering EVERY FILE to 0755
+ # -- the 0644 unit, the SDK library and the polkit .policy included --
+ # destroying the mode discipline assemble_daemon_root() asserts.
+ #
+ # The .desktop file and the hicolor icons ride as plain pacman-shipped files
+ # ON PURPOSE, exactly as they ride as plain dpkg and rpm files in the other
+ # two packages: /usr/share/libalpm/hooks/ ships desktop-file-utils' and
+ # gtk-update-icon-cache's own hooks targeting usr/share/applications/ and
+ # usr/share/icons/, so mimeinfo.cache (what an x-scheme-handler/urnetwork
+ # lookup actually consults) and the icon cache refresh with zero scriptlet
+ # code. Never call update-desktop-database from post_install.
+ #
+ # The polkit action file rides inside this same /usr tree, put there by
+ # assemble_daemon_root(), which also asserts its 0644 mode before the package
+ # is built. It gets no second explicit entry here: a duplicated member is a
+ # packaging bug, not a stronger guarantee.
+ - src: "${PKGROOT}/usr/**/*"
+ dst: /usr
+ # Admin-editable integration files. `type: config` maps to pacman's
+ # `backup = etc/...` array, which is the exact analogue of a dpkg conffile
+ # and of rpm's %config(noreplace): an edited file survives an upgrade and the
+ # package's version lands beside it as .pacnew, and on removal the edited
+ # file is kept as .pacsave.
+ - src: "${PKGROOT}/etc/urnetwork/autostart/com.bringyour.network.desktop"
+ dst: /etc/urnetwork/autostart/com.bringyour.network.desktop
+ type: config
+ - src: "${PKGROOT}/etc/NetworkManager/conf.d/95-urnetwork.conf"
+ dst: /etc/NetworkManager/conf.d/95-urnetwork.conf
+ type: config
+ - src: "${PKGROOT}/etc/udev/rules.d/85-urnetwork-unmanaged.rules"
+ dst: /etc/udev/rules.d/85-urnetwork-unmanaged.rules
+ type: config
+
+# THE SIX HOOKS, AND WHY ALL SIX MUST BE DECLARED HERE.
+#
+# pacman's .INSTALL is not a set of Debian maintainer scripts and not a set of
+# rpm scriptlets: it defines shell FUNCTIONS, and pacman dispatches to them by
+# TRANSACTION KIND, with version strings as arguments and no overlap:
+#
+# fresh install pre_install "$new" post_install "$new"
+# upgrade pre_upgrade "$new" "$old" post_upgrade "$new" "$old"
+# downgrade pre_upgrade "$new" "$old" post_upgrade "$new" "$old"
+# reinstall pre_upgrade "$v" "$v" post_upgrade "$v" "$v"
+# removal pre_remove "$old" post_remove "$old"
+#
+# The install pair NEVER runs on an upgrade, and the remove pair never runs on
+# one either -- so unlike dpkg (whose postinst re-runs its whole `configure`
+# branch on upgrade) and unlike rpm (whose %post runs with $1 >= 2), Arch gives
+# an upgrade NOTHING unless pre_upgrade/post_upgrade exist.
+#
+# And they only exist if they are declared in THIS BLOCK. nfpm's generic
+# `scripts:` maps to pre_install/post_install/pre_remove/post_remove and
+# NOTHING ELSE -- `nfpm jsonschema` shows `Scripts` with exactly those four
+# keys and `ArchLinuxScripts` with exactly preupgrade/postupgrade, both
+# additionalProperties:false, and there is no fallback wiring between them.
+# Measured: a package carrying only the four generic scripts installs fine and
+# then runs ABSOLUTELY NOTHING on `pacman -U` of a newer build -- the binary is
+# replaced under a still-running daemon that keeps executing the old code, with
+# no daemon-reload and no restart, and pacman reports success.
+#
+# The same trap is a forward-compatibility trap: because post_install never
+# runs on upgrade, anything ADDED to it in a future release is invisible to
+# every existing user forever. Whatever must hold after every transaction has
+# to be idempotent and reachable from BOTH -- which is why post_upgrade here
+# calls post_install rather than restating it. (Both function bodies land in
+# one sourced .INSTALL file, so that call is just a shell function call; it is
+# the same shape a hand-written PKGBUILD .install uses.)
+scripts:
+ preinstall: scripts/pre_install
+ postinstall: scripts/post_install
+ preremove: scripts/pre_remove
+ postremove: scripts/post_remove
+archlinux:
+ # Explicit, not inferred. The top-level `arch:` already produces the right
+ # value, but make-arch.sh needs the pacman spelling for the artifact name and
+ # this keeps the two from ever disagreeing.
+ arch: "${PKG_ARCH}"
+ # REQUIRED, and easy to lose by copying either sibling config: the top-level
+ # `maintainer:` does NOT feed .PKGINFO's packager field. Measured -- with
+ # maintainer set and this key absent, `pacman -Qi` reports
+ # "Packager : Unknown Packager".
+ packager: "URnetwork "
+ scripts:
+ preupgrade: scripts/pre_upgrade
+ postupgrade: scripts/post_upgrade
+
+# ---------------------------------------------------------------------------
+# PORTABILITY NOTE -- WHAT THIS PACKAGE IS AND IS NOT
+# ---------------------------------------------------------------------------
+# This is the ARCH-FAMILY package: Arch, CachyOS, EndeavourOS, Manjaro. Every
+# dependency name above was checked against the real repositories (glibc,
+# iproute2, shadow, systemd in core; nftables, fuse2 in extra), so it resolves
+# on any of them.
+#
+# IT IS NOT FOR SteamOS, which is Arch-family but immutable. SteamOS has a
+# read-only /usr; `pacman -U` there needs `steamos-readonly disable` and is
+# reverted by the next system update. Immutable Arch hosts take the install
+# tarball instead, which handles a read-only /usr by installing under
+# /usr/local. packaging/distro-smoke.sh says so on a SteamOS host.
+#
+# NO conflicts:/provides:/replaces: are declared. There is no urnetwork package
+# in the Arch repositories or the AUR today, so there is nothing to conflict
+# with by name; the real collision is with a tarball install of the same files,
+# and pacman cannot see that. packaging/tarball/install.sh and uninstall.sh
+# already guard the other direction -- both refuse to touch paths that
+# `pacman -Qo` says a package owns.
diff --git a/packaging/arch/scripts/post_install b/packaging/arch/scripts/post_install
new file mode 100644
index 0000000..9e7e097
--- /dev/null
+++ b/packaging/arch/scripts/post_install
@@ -0,0 +1,178 @@
+#!/bin/sh
+# post_install() for urnetwork-daemon -- and, via post_upgrade, the ONE place
+# that converges this machine after EVERY pacman transaction.
+#
+# PACMAN ARGUMENTS: "$1" is the version just installed. "$2" is unset.
+# post_install runs ONLY on a fresh install -- never on an upgrade, never on a
+# downgrade, and never on a reinstall (all three go through the pre_upgrade /
+# post_upgrade pair instead). That is the single biggest difference from the
+# other two packages: packaging/deb/scripts/postinst re-runs its whole
+# `configure` branch on every upgrade, and packaging/rpm/scripts/post runs on
+# upgrade with $1 >= 2. Arch runs neither.
+#
+# WHICH IS WHY post_upgrade CALLS THIS FUNCTION rather than restating it.
+# nfpm emits every hook into ONE .INSTALL file which pacman sources whole
+# before calling anything, so `post_install "$1"` from post_upgrade is an
+# ordinary shell function call -- the same shape a hand-written PKGBUILD
+# .install uses (`post_upgrade() { post_install "$@"; }`). The consequence to
+# keep in mind when editing: ANYTHING ADDED BELOW MUST BE IDEMPOTENT, because
+# it will run again on every upgrade, downgrade and reinstall. That is the
+# property that makes it safe, and it is also the only defence against the
+# forward-compatibility trap -- work added to an install-only hook on Arch is
+# invisible to every existing user forever.
+#
+# UR_ARCH_PHASE is how the two callers differ. post_upgrade sets it to
+# "upgrade" before calling; a real install leaves it unset. It is NOT derived
+# from "$1"/"$2" on purpose: the version strings alone cannot distinguish an
+# upgrade from a downgrade from a reinstall, and every attempt to read meaning
+# out of them is the same class of mistake as branching on 'configure'.
+#
+# Deliberately absent, mirroring both siblings: anything touching
+# /usr/share/applications or /usr/share/icons. Those ship as plain
+# pacman-owned files precisely so the desktop-file-utils and
+# gtk-update-icon-cache alpm hooks in /usr/share/libalpm/hooks refresh
+# mimeinfo.cache -- the thing an x-scheme-handler/urnetwork lookup actually
+# consults -- and the icon cache, with zero scriptlet code.
+set -e
+
+UNIT='urnetworkd.service'
+UR_PHASE="${UR_ARCH_PHASE:-install}"
+RESTART_MARKER='/run/urnetwork.pacman-restart'
+
+# --- which authority this machine will run under, said out loud --------------
+#
+# DETECTED BY LOOKING FOR POLKIT, NOT FOR OUR OWN ACTION FILE. pacman has
+# already extracted /usr/share/polkit-1/actions/com.bringyour.network.policy by
+# the time this runs (and created that directory on the way), so testing the
+# file or the directory would report "polkit" on a box that has never had it.
+#
+# This mirrors the daemon's own two-fact test exactly:
+# ControlServer::PolkitRuntimePresent() probes /usr/bin/pkcheck and
+# /usr/lib/polkit-1/polkitd among others, and PolkitPolicyPresent() requires
+# BOTH that and the action file -- so a machine with the file and no runtime
+# falls back to the group rather than latching an authority it cannot ask.
+# Unlike the .deb, this package declares no polkit dependency at all (see
+# nfpm.yaml for the four reasons), so on Arch this branch is genuinely load
+# bearing rather than a formality.
+UR_HAS_POLKIT=0
+if command -v pkaction >/dev/null 2>&1 || command -v pkcheck >/dev/null 2>&1; then
+ UR_HAS_POLKIT=1
+elif [ -f /usr/share/polkit-1/actions/org.freedesktop.policykit.policy ]; then
+ UR_HAS_POLKIT=1
+else
+ for d in /usr/lib/polkit-1 /usr/libexec/polkit-1 /usr/lib64/polkit-1; do
+ if [ -x "${d}/polkitd" ]; then UR_HAS_POLKIT=1; break; fi
+ done
+fi
+# Said once, on a fresh install. An upgrade repeats none of this chatter.
+if [ "${UR_PHASE}" = install ]; then
+ if [ "${UR_HAS_POLKIT}" = 1 ]; then
+ echo "urnetwork: permission is granted through polkit -- no group change and no log-out are needed."
+ if [ -n "$(getent group urnetwork 2>/dev/null | cut -d: -f4)" ]; then
+ echo "urnetwork: the 'urnetwork' group still has members from an earlier version. Nothing to undo -- membership is simply no longer consulted where polkit is present."
+ fi
+ else
+ echo "urnetwork: no polkit found; urnetworkd falls back to the 'urnetwork' group."
+ echo "urnetwork: run 'sudo usermod -aG urnetwork ', then log out and back in."
+ echo "urnetwork: or 'sudo pacman -S polkit' and reinstall this package, which removes that requirement."
+ fi
+fi
+
+# --- the polkit action file's mode -------------------------------------------
+# polkit IGNORES a group- or world-writable .policy file, SILENTLY: the actions
+# revert to their built-in defaults and the only symptom is an administrator
+# password prompt on every Connect, long after the install. pacman ships ours
+# 0644 root:root from the package tree (assemble_daemon_root asserts that at
+# build time); assert it here rather than assume it, and check the directory
+# too -- polkit does not police the directory, and whoever can write it can
+# drop in a file that redefines our defaults.
+UR_POLICY=/usr/share/polkit-1/actions/com.bringyour.network.policy
+if [ -f "${UR_POLICY}" ]; then
+ chmod 0644 "${UR_POLICY}" 2>/dev/null || :
+ chown root:root "${UR_POLICY}" 2>/dev/null || :
+ UR_POLICY_DIR_MODE="$(stat -c '%a' /usr/share/polkit-1/actions 2>/dev/null || echo '')"
+ case "${UR_POLICY_DIR_MODE}" in
+ '') : ;;
+ *[2367]|*[2367]?)
+ echo "urnetwork: WARNING /usr/share/polkit-1/actions is group- or world-writable (mode ${UR_POLICY_DIR_MODE}); anyone who can write there can redefine URnetwork's polkit defaults." >&2 ;;
+ esac
+fi
+
+# --- /run/urnetwork -----------------------------------------------------------
+# The unit's RuntimeDirectory=urnetwork (RuntimeDirectoryPreserve=yes) is the
+# real owner and re-applies the mode on every start; pre-create it so the
+# control path exists even before the first start. The daemon narrows it to
+# 0750 root:urnetwork itself when it finds no polkit action file.
+if [ -d /run ]; then
+ if [ "${UR_HAS_POLKIT}" = 1 ]; then
+ install -d -m 0755 -o root -g root /run/urnetwork 2>/dev/null || :
+ else
+ install -d -m 0750 -o root -g urnetwork /run/urnetwork 2>/dev/null || :
+ fi
+fi
+
+# --- NetworkManager / udev markings -------------------------------------------
+# The conf.d marking must be live before the first tunnel comes up, and both
+# files are `backup =` conffiles that an upgrade may replace -- which is a
+# second reason this block has to be reachable from post_upgrade. Neither call
+# touches existing connections.
+if command -v nmcli >/dev/null 2>&1; then
+ nmcli general reload conf >/dev/null 2>&1 || :
+fi
+if command -v udevadm >/dev/null 2>&1; then
+ udevadm control --reload >/dev/null 2>&1 || :
+fi
+
+# --- systemd ------------------------------------------------------------------
+# THE daemon-reload IS NOT REDUNDANT with Arch's own
+# /usr/share/libalpm/hooks/30-systemd-daemon-reload-system.hook. That hook is
+# PostTransaction -- it runs AFTER this function -- so starting the unit here
+# without reloading first would start it against the stale in-memory unit.
+if [ ! -d /run/systemd/system ]; then
+ # No booted systemd (a chroot, a container image build, pacstrap). Nothing
+ # to enable or start; the unit is on disk and will be picked up at boot.
+ return 0
+fi
+systemctl --system daemon-reload >/dev/null 2>&1 || :
+
+if [ "${UR_PHASE}" = install ]; then
+ # ENABLE, AND ONLY EVER HERE.
+ #
+ # Arch has no preset mechanism to lean on: its
+ # /usr/lib/systemd/system-preset/99-default.preset ends in `disable *`, and
+ # unlike Fedora there is no convention of shipping a package preset file --
+ # so `systemctl preset` would leave the unit disabled and this package
+ # would install a service that never starts, at install or at boot.
+ #
+ # Enabling on the INITIAL INSTALL ONLY is how an admin's later `systemctl
+ # disable urnetworkd` survives every upgrade. It needs no state file: the
+ # enablement symlink lives in /etc/systemd/system/multi-user.target.wants/,
+ # which pacman does not touch on upgrade, and post_upgrade deliberately
+ # does not re-enable. This is the Arch expression of the .deb's
+ # deb-systemd-helper "enable once" state file and the .rpm's
+ # `[ $1 -eq 1 ]` preset guard -- same guarantee, no equivalent machinery
+ # required.
+ systemctl enable "${UNIT}" >/dev/null 2>&1 || :
+ # STARTING FROM A SCRIPTLET is correct here for the same reason Debian
+ # Policy 9.3.3.1 permits it for the .deb and the .rpm does it too:
+ # urnetworkd STARTS IDLE. It brings up no tunnel, changes no routes and
+ # installs no firewall rules until a client authenticates on the control
+ # socket and asks. The alternative is a `pacman -S` that leaves a machine
+ # where the app truthfully reports "the service is not running" until the
+ # next reboot.
+ systemctl start "${UNIT}" >/dev/null 2>&1 || :
+else
+ # UPGRADE / DOWNGRADE / REINSTALL: start again ONLY if pre_upgrade stopped
+ # a unit that was actually running. An upgrade must never start a daemon an
+ # admin had stopped, and must never re-enable one they had disabled.
+ if [ -f "${RESTART_MARKER}" ]; then
+ rm -f "${RESTART_MARKER}" 2>/dev/null || :
+ systemctl start "${UNIT}" >/dev/null 2>&1 || :
+ fi
+fi
+
+# `return 0`, never `exit 0` -- see the note at the end of pre_install. This
+# one matters in practice rather than in theory: post_upgrade calls this
+# function and then returns, and an `exit` here would end pacman's scriptlet
+# shell mid-call.
+return 0
diff --git a/packaging/arch/scripts/post_remove b/packaging/arch/scripts/post_remove
new file mode 100644
index 0000000..0af13c3
--- /dev/null
+++ b/packaging/arch/scripts/post_remove
@@ -0,0 +1,58 @@
+#!/bin/sh
+# post_remove() for urnetwork-daemon.
+#
+# PACMAN ARGUMENT: "$1" is the version that was removed. "$2" is unset. Runs
+# only on a real removal, never on an upgrade.
+#
+# WHAT DELIBERATELY DOES *NOT* HAPPEN HERE, and why it differs from
+# packaging/deb/scripts/postrm.
+#
+# dpkg has two removal states and the .deb uses both: on `remove` it masks the
+# unit, and only on `purge` does it delete /var/lib/urnetwork (device identity
+# and key material), /etc/urnetwork, an URnetwork-authored /etc/geolocation,
+# and the urnetwork group.
+#
+# PACMAN HAS NO PURGE. `pacman -R` and even `pacman -Rns` are the equivalent of
+# `apt remove`, not of `apt purge`: pacman removes the files it owns, keeps
+# modified `backup =` files as .pacsave, and has no second state in which a
+# package is entitled to delete user data or a system group other files may
+# still be group-owned by. So this script removes none of it -- and says
+# exactly how to, rather than silently keeping key material or silently
+# deleting it. packaging/rpm/scripts/postun reaches the same conclusion for the
+# same reason.
+#
+# There is also no mask step. deb-systemd-helper masks a unit while it is
+# removed-but-not-purged so nothing restarts a service whose binary is gone;
+# with no purge state there is nothing to unmask later, and pre_remove has
+# already disabled the unit, so masking would only leave a permanent
+# /etc/systemd/system/urnetworkd.service -> /dev/null symlink behind after the
+# package that created it is gone.
+set -e
+
+# The unit file is gone; tell systemd. Arch's own
+# /usr/share/libalpm/hooks/30-systemd-daemon-reload-system.hook fires on
+# Remove for usr/lib/systemd/system/* too, but it is PostTransaction and this
+# costs nothing, so correct behaviour never depends on the hook being present.
+if [ -d /run/systemd/system ]; then
+ systemctl --system daemon-reload >/dev/null 2>&1 || :
+fi
+
+# /run/urnetwork survives a stop on purpose (RuntimeDirectoryPreserve=yes in
+# the unit, so a crash-restart comes back armed rather than open). With the
+# daemon gone it is a leftover holding a marker nothing will ever read again.
+if [ -d /run/urnetwork ]; then
+ rm -rf /run/urnetwork 2>/dev/null || :
+fi
+rm -f /run/urnetwork.pacman-restart 2>/dev/null || :
+
+echo "urnetwork: removed. Device credentials and settings were KEPT (pacman has no 'purge')."
+echo "urnetwork: to erase them too: sudo rm -rf /var/lib/urnetwork /etc/urnetwork"
+echo "urnetwork: the 'urnetwork' group was kept: sudo groupdel urnetwork"
+# Only ever mention the location override when URnetwork demonstrably authored
+# it: the writer stamps the file with a marker line, and no other actor on the
+# system reverts it.
+if [ -f /etc/geolocation ] && grep -qi 'urnetwork' /etc/geolocation 2>/dev/null; then
+ echo "urnetwork: /etc/geolocation still holds an URnetwork location override: sudo rm -f /etc/geolocation"
+fi
+
+return 0
diff --git a/packaging/arch/scripts/post_upgrade b/packaging/arch/scripts/post_upgrade
new file mode 100644
index 0000000..962f0bc
--- /dev/null
+++ b/packaging/arch/scripts/post_upgrade
@@ -0,0 +1,44 @@
+#!/bin/sh
+# post_upgrade() for urnetwork-daemon -- the start half of the upgrade dance,
+# and the reason nothing in post_install is unreachable on Arch.
+#
+# PACMAN ARGUMENTS: "$1" incoming version, "$2" outgoing version. Runs on
+# UPGRADE, DOWNGRADE and REINSTALL alike (see pre_upgrade); never on a fresh
+# install.
+#
+# THE ENTIRE BODY IS A DELEGATION, ON PURPOSE. post_install holds the work that
+# must be true after every transaction -- the polkit authority detection, the
+# 0644 assertion on the action file, the /run/urnetwork pre-create, the
+# NetworkManager and udev reloads (both of those files are `backup =`
+# conffiles an upgrade may have replaced), and the systemd daemon-reload.
+# Restating any of it here would guarantee the two copies drift; worse, the
+# NEXT thing added to post_install would silently never reach an existing user,
+# because Arch does not run the install hooks on an upgrade the way dpkg and
+# rpm both do.
+#
+# This works because nfpm emits every hook into ONE .INSTALL file and pacman
+# sources the whole file before calling any function, so post_install is
+# already defined here. It is also the idiomatic PKGBUILD .install shape
+# (`post_upgrade() { post_install "$@"; }`); the only addition is the phase
+# variable, so post_install can tell the two callers apart without trying to
+# infer it from the version strings.
+#
+# WHAT post_install DOES DIFFERENTLY IN THIS PHASE, and why:
+# * it does NOT re-enable the unit -- enabling exactly once, on the initial
+# install, is what makes an admin's `systemctl disable` survive upgrades on
+# a distribution with no deb-systemd-helper state file and no preset
+# convention;
+# * it starts the unit only if pre_upgrade recorded that it was running, so
+# an upgrade never starts a daemon an admin had stopped;
+# * it stays quiet -- the first-run polkit/group guidance is printed on a
+# fresh install only.
+set -e
+
+# Set BEFORE the call: post_install reads it as ${UR_ARCH_PHASE:-install}, and
+# both functions run in the same shell process, so a plain variable is enough.
+UR_ARCH_PHASE=upgrade
+export UR_ARCH_PHASE
+
+post_install "$1"
+
+return 0
diff --git a/packaging/arch/scripts/pre_install b/packaging/arch/scripts/pre_install
new file mode 100644
index 0000000..a0bb1dd
--- /dev/null
+++ b/packaging/arch/scripts/pre_install
@@ -0,0 +1,47 @@
+#!/bin/sh
+# pre_install() for urnetwork-daemon -- the Arch install hook.
+#
+# PACMAN ARGUMENT: "$1" is the version being installed ("2026.8.20.1024376890
+# .beta-1"). There is no "$2" AT ALL -- not empty, UNSET (measured: nargs=1,
+# "${2-UNSET}" -> UNSET), so a bare $2 aborts the scriptlet under `set -u`.
+#
+# THIS IS NOT A DEBIAN MAINTAINER SCRIPT AND NOT AN RPM SCRIPTLET. pacman never
+# passes 'configure', 'remove' or 'purge', and never passes 0/1/2 either. Any
+# `case "$1" in configure)` or `[ "$1" -eq 1 ]` branch carried over from
+# packaging/deb/scripts/ or packaging/rpm/scripts/ would simply never match,
+# and the failure is SILENT -- the package installs with exit code 0 and does
+# nothing. Each function here IS its own branch; that is the whole point of
+# there being six of them.
+#
+# WHAT THIS DOES: creates the `urnetwork` system group, and nothing else.
+#
+# It is here rather than in post_install for the ordering reason Fedora's
+# guidelines give and packaging/rpm/scripts/pre already follows: post_install
+# pre-creates /run/urnetwork with `-g urnetwork` on a machine with no polkit,
+# so the group has to exist before any file that references it. pacman runs
+# pre_install before extracting a single file, which is exactly the right slot.
+#
+# THE GROUP IS THE FALLBACK AUTHORIZER, AND NOBODY IS PUT IN IT. Where polkit
+# is present the socket is authorized per-action against the peer's SO_PEERCRED
+# uid and membership is inert. Where it is absent, urnetworkd binds the socket
+# 0660 root:urnetwork and consults this group. The `usermod -aG` + "log out and
+# back in" pair that used to follow is deliberately gone: supplementary groups
+# are applied at LOGIN, so it made a correct install unusable until the user
+# ended their session. See packaging/deb/scripts/postinst, which this mirrors.
+#
+# Created on install and DELIBERATELY NEVER REMOVED -- see post_remove.
+set -e
+
+# `groupadd --system`, not Debian's `addgroup`: there is no addgroup on Arch.
+# groupadd comes from `shadow`, which this package depends on.
+if ! getent group urnetwork >/dev/null 2>&1; then
+ groupadd --system urnetwork >/dev/null 2>&1 || :
+fi
+
+# `return 0`, NEVER `exit 0`. nfpm inlines this file VERBATIM inside
+# `function pre_install() { ... }` in the generated .INSTALL, so an `exit`
+# terminates pacman's whole scriptlet shell rather than this function --
+# harmless as the very last statement, fatal for any function that calls
+# another (post_upgrade calls post_install, and measured, a trailing `exit 0`
+# in the callee silently swallowed everything after the call).
+return 0
diff --git a/packaging/arch/scripts/pre_remove b/packaging/arch/scripts/pre_remove
new file mode 100644
index 0000000..d7a43cf
--- /dev/null
+++ b/packaging/arch/scripts/pre_remove
@@ -0,0 +1,51 @@
+#!/bin/sh
+# pre_remove() for urnetwork-daemon.
+#
+# PACMAN ARGUMENT: "$1" is the version being removed. "$2" is unset.
+# Runs ONLY on a real removal -- NOT on an upgrade, and not on a downgrade or a
+# reinstall either. That is the opposite of dpkg, where prerm runs on both
+# remove and upgrade and packaging/deb/scripts/prerm relies on it. Nothing here
+# needs an "am I being upgraded?" guard, because pacman never calls this
+# function in that case.
+#
+# `disable --now`, AND IT MUST HAPPEN HERE RATHER THAN IN post_remove. This is
+# a real safety property of this package, not a formality, and it is the same
+# argument packaging/rpm/scripts/preun makes at length:
+#
+# The unit's ExecStopPost= is
+# /usr/lib/urnetwork/urnetworkd --revert-unless-armed
+# i.e. THE DAEMON'S OWN BINARY is what removes `table inet urnetwork`, the
+# policy rules and the capture routes on every stop path. pre_remove runs
+# BEFORE pacman deletes a single file, so that binary still exists when
+# systemd runs it. Move the stop to post_remove and the binary is already
+# gone, ExecStopPost fails 203/EXEC, and a removal can leave the kill-switch
+# ruleset in the kernel with nothing left on the machine able to lift it --
+# a box off the network after `pacman -R`. Do not move this.
+#
+# `systemctl` directly: there is no deb-systemd-helper and no deb-systemd-invoke
+# on Arch, so there is no enable-state file to consult and no policy-rc.d to
+# respect. Those two mechanisms have no Arch equivalent and are deliberately
+# dropped rather than faked.
+set -e
+
+UNIT='urnetworkd.service'
+
+if [ -d /run/systemd/system ]; then
+ # THE ARMED KILL SWITCH IS THE ONE THING A REMOVAL CANNOT FIX FOR ITSELF.
+ # /run/urnetwork/kill-switch-armed is the marker that says "this machine
+ # was armed when the daemon last stopped", and --revert-unless-armed
+ # deliberately PRESERVES the block floor in that case so a crash does not
+ # open a window. That design turns a removal into a trap: the floor stays
+ # in the kernel and the only binary that knows how to remove it is about to
+ # be deleted. Checked while the marker and the binary both still exist, and
+ # only reported -- silently lifting a firewall the user asked for would be
+ # its own bug, and the recovery is one command.
+ if [ -f /run/urnetwork/kill-switch-armed ]; then
+ echo "urnetwork: WARNING -- the kill switch is ARMED on this machine." >&2
+ echo "urnetwork: removing the package leaves that nftables floor in place. Lift it with:" >&2
+ echo "urnetwork: sudo nft delete table inet urnetwork" >&2
+ fi
+ systemctl --no-reload disable --now "${UNIT}" >/dev/null 2>&1 || :
+fi
+
+return 0
diff --git a/packaging/arch/scripts/pre_upgrade b/packaging/arch/scripts/pre_upgrade
new file mode 100644
index 0000000..be60219
--- /dev/null
+++ b/packaging/arch/scripts/pre_upgrade
@@ -0,0 +1,62 @@
+#!/bin/sh
+# pre_upgrade() for urnetwork-daemon -- the stop half of the upgrade dance.
+#
+# PACMAN ARGUMENTS: "$1" is the INCOMING version, "$2" the OUTGOING one.
+# Read them as incoming/outgoing, NOT as new/old: this same function runs on a
+# DOWNGRADE (measured: arg1=2026.8.11-1 arg2=2026.8.12-1, i.e. arg1 lower) and
+# on a REINSTALL of the identical version (arg1 == arg2). Any "upgrading from
+# older than X" logic keyed on "$2 is old, $1 is new" is wrong in two of the
+# three cases, so this script reads neither.
+#
+# THE REINSTALL CASE IS THE ONE TO REMEMBER: `pacman -U` of the same file, and
+# `pacman -S` of an already-installed package, both route HERE and not through
+# pre_install/post_install. Without this hook declared, a tester's "just
+# reinstall the package" is a silent no-op -- which is very likely how the
+# omission would first be noticed in the field.
+#
+# WHY THE STOP IS HERE AND NOT AFTER THE FILES MOVE.
+#
+# pacman has not replaced a single file yet when this runs, so
+# /usr/lib/urnetwork/urnetworkd is still the OLD binary -- and the unit's
+# ExecStopPost=-/usr/lib/urnetwork/urnetworkd --revert-unless-armed
+# is what removes `table inet urnetwork`, the policy rules and the capture
+# routes on every stop path. Stopping now means that teardown is performed by
+# the exact binary that built the ruleset. This is the .deb's discipline
+# verbatim (packaging/deb/scripts/prerm: "stop the unit on BOTH remove and
+# upgrade ... The daemon holds a live tun fd; the upgrade discipline is stop
+# (old binary) -> replace files -> start (new binary)"). The .rpm cannot do
+# this because rpm has no pre-replacement slot for the OLD package; pacman
+# does, so this package takes it.
+#
+# THE OTHER HALF OF THE ARCH DIFFERENCE: pre_remove/post_remove do NOT run on
+# an upgrade either. On Debian the discipline is split across two scripts --
+# prerm stops, postinst starts. Here there is no such split to inherit: the
+# entire stop-then-start dance has to live in pre_upgrade/post_upgrade, or it
+# does not happen at all.
+set -e
+
+UNIT='urnetworkd.service'
+RESTART_MARKER='/run/urnetwork.pacman-restart'
+
+# Record whether the daemon was RUNNING, so post_upgrade can put it back the
+# way it found it and no other way. There is no `try-restart` shortcut
+# available: by the time post_upgrade runs we will have stopped the unit
+# ourselves, so "is it active now" no longer answers "was it active before".
+#
+# /run is a tmpfs owned by root and is guaranteed writable here -- this whole
+# block only runs when systemd is booted, which means we are root on a live
+# system. A stale marker (an interrupted transaction) can only cause an extra
+# start of a daemon that comes up idle, which is the safe direction.
+rm -f "${RESTART_MARKER}" 2>/dev/null || :
+if [ -d /run/systemd/system ]; then
+ if systemctl is-active --quiet "${UNIT}" 2>/dev/null; then
+ : > "${RESTART_MARKER}" 2>/dev/null || :
+ fi
+ # `stop`, never `disable`: enablement is post_install's business exactly
+ # once, and an upgrade that disabled and re-enabled would quietly undo an
+ # admin's decision. Stopping tears the tunnel down through the daemon's own
+ # shutdown path; it comes back idle, exactly like a reboot.
+ systemctl stop "${UNIT}" >/dev/null 2>&1 || :
+fi
+
+return 0
diff --git a/packaging/deb/nfpm.yaml b/packaging/deb/nfpm.yaml
index f2d8605..4877706 100644
--- a/packaging/deb/nfpm.yaml
+++ b/packaging/deb/nfpm.yaml
@@ -97,7 +97,7 @@ contents:
# APPIMAGE.md 11d).
#
# The polkit action file rides inside this same /usr tree, at
- # usr/share/polkit-1/actions/network.ur.urnetwork.policy, put there by
+ # usr/share/polkit-1/actions/com.bringyour.network.policy, put there by
# linux/packaging/lib/common.sh (assemble_daemon_root), which also asserts
# its 0644 mode before the package is built. It is deliberately NOT given a
# second explicit contents entry: nfpm would then emit the path twice, and
@@ -112,8 +112,8 @@ contents:
dst: /lib
type: tree
# Admin-editable integration files: conffiles, preserved on upgrade.
- - src: "${PKGROOT}/etc/urnetwork/autostart/network.ur.urnetwork.desktop"
- dst: /etc/urnetwork/autostart/network.ur.urnetwork.desktop
+ - src: "${PKGROOT}/etc/urnetwork/autostart/com.bringyour.network.desktop"
+ dst: /etc/urnetwork/autostart/com.bringyour.network.desktop
type: config
- src: "${PKGROOT}/etc/NetworkManager/conf.d/95-urnetwork.conf"
dst: /etc/NetworkManager/conf.d/95-urnetwork.conf
diff --git a/packaging/deb/scripts/postinst b/packaging/deb/scripts/postinst
index ff6ebae..cc2e41b 100644
--- a/packaging/deb/scripts/postinst
+++ b/packaging/deb/scripts/postinst
@@ -57,7 +57,7 @@ case "$1" in
# Which authority this machine will run under, said out loud.
#
# DETECTED BY LOOKING FOR POLKIT, NOT FOR OUR OWN ACTION FILE: dpkg has
- # already unpacked /usr/share/polkit-1/actions/network.ur.urnetwork
+ # already unpacked /usr/share/polkit-1/actions/com.bringyour.network
# .policy by the time this runs (and created that directory on the way),
# so testing the file or the directory would report "polkit" on a box
# that has never had it. polkitd is a Recommends, so apt pulls it in by
@@ -88,7 +88,7 @@ case "$1" in
# from the package tree; assert it rather than assume it, and check the
# directory too (polkit does not police the directory, and whoever can
# write it can drop in a file that redefines our defaults).
- UR_POLICY=/usr/share/polkit-1/actions/network.ur.urnetwork.policy
+ UR_POLICY=/usr/share/polkit-1/actions/com.bringyour.network.policy
if [ -f "${UR_POLICY}" ]; then
chmod 0644 "${UR_POLICY}" 2>/dev/null || true
chown root:root "${UR_POLICY}" 2>/dev/null || true
diff --git a/packaging/distro-smoke.sh b/packaging/distro-smoke.sh
index fdd923c..56eb03b 100755
--- a/packaging/distro-smoke.sh
+++ b/packaging/distro-smoke.sh
@@ -305,14 +305,18 @@ fi
# ==========================================================================
head2 "2. how URnetwork gets installed here"
# ==========================================================================
-# The release publishes exactly four assets today and their names are a
-# contract (the in-app service checker parses them):
+# The release asset names are a contract (the in-app service checker parses
+# them):
# urnetwork-daemon--.install.tar.gz
# urnetwork-daemon__.deb
-# URnetwork--.AppImage (+ .zsync)
-# There is NO .rpm and NO flatpak bundle in the release yet. On an rpm distro
-# the tarball installer is the whole story, and this check says so rather than
-# implying a package that does not exist.
+# urnetwork-daemon-..rpm
+# urnetwork-daemon--.pkg.tar.zst
+# URnetwork--.AppImage (+ .zsync, + .flatpak)
+# All four daemon packages are the same payload out of one staging tree
+# (packaging/lib/common.sh assemble_daemon_root), so "which channel" is a
+# question about THIS HOST, not about which build is newer. Whether a given
+# release actually carries a given package is still a fact about the RELEASE,
+# which is why the branches below hedge rather than promise.
PKG_CHANNEL=''
if have dpkg; then
@@ -334,7 +338,15 @@ elif have apk; then
check 2.1 BLOCKER "native package format" "apk (Alpine): musl, and no musl build exists"
elif have pacman; then
PKG_CHANNEL='pacman'
- check 2.1 WARN "native package format" "pacman present; no Arch package is published -- use the tarball"
+ check 2.1 ok "native package format" "pacman present -> urnetwork-daemon--$(uname -m).pkg.tar.zst, if the release carries one"
+ note "Install it with: sudo pacman -U ./urnetwork-daemon--$(uname -m).pkg.tar.zst"
+ note "It declares nftables and fuse2, which a minimal Arch install does not have and"
+ note "which the tarball can only tell you about after the fact. If the release has no"
+ note "pacman package, the install tarball works here too -- but never both on one"
+ note "machine: each refuses to overwrite the other's files."
+ if [ -r /usr/lib/os-release ] && grep -qi '^ID=steamos' /usr/lib/os-release 2>/dev/null; then
+ check 2.1 WARN "native package format" "SteamOS: /usr is read-only, 'pacman -U' needs 'steamos-readonly disable' and the next system update reverts it -- use the install tarball, which installs under /usr/local"
+ fi
elif have zypper; then
PKG_CHANNEL='zypper'
check 2.1 WARN "native package format" "zypper present; no .rpm is published -- use the tarball"
@@ -347,7 +359,7 @@ if have flatpak; then
FP_REMOTES="${FP_REMOTES%% }"
check 2.2 ok "flatpak" "present; remotes: ${FP_REMOTES:-}"
note "No .flatpak bundle is published in the release yet either -- the manifest"
- note "at packaging/flatpak/network.ur.urnetwork.yml builds one locally."
+ note "at packaging/flatpak/com.bringyour.network.yml builds one locally."
else
check 2.2 n/a "flatpak" "not installed (only matters for the GUI, never the daemon)"
fi
@@ -371,8 +383,10 @@ elif [ -n "${FUSE2}" ]; then
else
check 2.3 WARN "AppImage runtime (libfuse2)" "libfuse.so.2 not found -- the GUI AppImage will not mount"
note "Fix per family: apt install libfuse2t64 (or libfuse2) | dnf install fuse-libs"
- note "Or run the AppImage with --appimage-extract-and-run. The .deb declares this"
- note "dependency for you; the tarball and the bare AppImage cannot."
+ note " pacman -S fuse2"
+ note "Or run the AppImage with --appimage-extract-and-run. The .deb and the pacman"
+ note "package declare this dependency for you; the tarball and the bare AppImage"
+ note "cannot."
fi
# The layout the tarball installer WILL choose, by the same rules install.sh
@@ -922,8 +936,8 @@ fi
GUI_FOUND=''
[ -x /usr/bin/urnetwork ] && GUI_FOUND="/usr/bin/urnetwork"
[ -x /usr/local/bin/urnetwork ] && GUI_FOUND="${GUI_FOUND} /usr/local/bin/urnetwork"
-if have flatpak && flatpak list --app --columns=application 2>/dev/null | grep -qx 'network.ur.urnetwork'; then
- GUI_FOUND="${GUI_FOUND} flatpak:network.ur.urnetwork"
+if have flatpak && flatpak list --app --columns=application 2>/dev/null | grep -qx 'com.bringyour.network'; then
+ GUI_FOUND="${GUI_FOUND} flatpak:com.bringyour.network"
fi
if [ -n "${GUI_FOUND}" ]; then
check 9.6 ok "GUI" "${GUI_FOUND# }"
diff --git a/packaging/flatpak/network.ur.urnetwork.yml b/packaging/flatpak/com.bringyour.network.yml
similarity index 97%
rename from packaging/flatpak/network.ur.urnetwork.yml
rename to packaging/flatpak/com.bringyour.network.yml
index 7bc1238..7be0e0a 100644
--- a/packaging/flatpak/network.ur.urnetwork.yml
+++ b/packaging/flatpak/com.bringyour.network.yml
@@ -23,8 +23,8 @@
# LOCAL INSTALL (the current target — Flathub submission comes later):
# flatpak install -y flathub org.gnome.Platform//49 org.gnome.Sdk//49
# flatpak run org.flatpak.Builder --force-clean --user --install \
-# build-flatpak packaging/flatpak/network.ur.urnetwork.yml
-# flatpak run network.ur.urnetwork
+# build-flatpak packaging/flatpak/com.bringyour.network.yml
+# flatpak run com.bringyour.network
#
# FOR FLATHUB, two changes are required and are deliberately NOT made here:
# 1. `sources: [{type: dir, path: ../..}]` must become a git/archive source
@@ -34,7 +34,7 @@
# redistribution; that audit (§7.17) must land before submission.
#
# SPDX-License-Identifier: MPL-2.0
-id: network.ur.urnetwork
+id: com.bringyour.network
runtime: org.gnome.Platform
runtime-version: '49'
sdk: org.gnome.Sdk
@@ -192,9 +192,9 @@ modules:
# no AppImage here and no /usr/bin to put a launcher in, so the Flatpak
# runs the GUI binary directly.
- desktop-file-edit --set-key=Exec --set-value="urnetwork-gui %u"
- /app/share/applications/network.ur.urnetwork.desktop
+ /app/share/applications/com.bringyour.network.desktop
- desktop-file-edit --remove-key=TryExec
- /app/share/applications/network.ur.urnetwork.desktop
+ /app/share/applications/com.bringyour.network.desktop
sources:
# LOCAL SOURCE — replace with a git/archive source before any Flathub
# submission (see the header). The path is relative to this manifest.
diff --git a/packaging/lib/common.sh b/packaging/lib/common.sh
index 2f58e19..f40bdae 100644
--- a/packaging/lib/common.sh
+++ b/packaging/lib/common.sh
@@ -124,11 +124,11 @@ assemble_daemon_root() {
# --- static integration files: canonical sources in app/packaging -------
local src="${APP_PACKAGING_DIR}"
local f
- for f in urnetwork-launcher urnetworkd.service network.ur.urnetwork.desktop \
- autostart/network.ur.urnetwork.desktop 95-urnetwork.conf \
+ for f in urnetwork-launcher urnetworkd.service com.bringyour.network.desktop \
+ autostart/com.bringyour.network.desktop 95-urnetwork.conf \
85-urnetwork-unmanaged.rules \
- icons/hicolor/48x48/apps/urnetwork.png \
- icons/hicolor/256x256/apps/urnetwork.png; do
+ icons/hicolor/48x48/apps/com.bringyour.network.png \
+ icons/hicolor/256x256/apps/com.bringyour.network.png; do
[ -f "${src}/${f}" ] || die "packaging source missing: ${src}/${f}"
done
@@ -140,17 +140,17 @@ assemble_daemon_root() {
cp "${src}/urnetworkd.service" "${root}/lib/systemd/system/urnetworkd.service"
install -d "${root}/usr/share/applications"
- cp "${src}/network.ur.urnetwork.desktop" "${root}/usr/share/applications/"
+ cp "${src}/com.bringyour.network.desktop" "${root}/usr/share/applications/"
install -d "${root}/usr/share/icons/hicolor/48x48/apps" \
"${root}/usr/share/icons/hicolor/256x256/apps"
- cp "${src}/icons/hicolor/48x48/apps/urnetwork.png" \
- "${root}/usr/share/icons/hicolor/48x48/apps/urnetwork.png"
- cp "${src}/icons/hicolor/256x256/apps/urnetwork.png" \
- "${root}/usr/share/icons/hicolor/256x256/apps/urnetwork.png"
+ cp "${src}/icons/hicolor/48x48/apps/com.bringyour.network.png" \
+ "${root}/usr/share/icons/hicolor/48x48/apps/com.bringyour.network.png"
+ cp "${src}/icons/hicolor/256x256/apps/com.bringyour.network.png" \
+ "${root}/usr/share/icons/hicolor/256x256/apps/com.bringyour.network.png"
install -d "${root}/etc/urnetwork/autostart"
- cp "${src}/autostart/network.ur.urnetwork.desktop" "${root}/etc/urnetwork/autostart/"
+ cp "${src}/autostart/com.bringyour.network.desktop" "${root}/etc/urnetwork/autostart/"
install -d "${root}/etc/NetworkManager/conf.d"
cp "${src}/95-urnetwork.conf" "${root}/etc/NetworkManager/conf.d/"
@@ -174,11 +174,11 @@ assemble_daemon_root() {
# world-writable .policy file, so a wrong mode here does not fail loudly,
# it silently drops every action back to its built-in default. The mode
# normalization below sets it; the assertion after it proves it.
- [ -f "${PACKAGING_DIR}/polkit/network.ur.urnetwork.policy" ] || \
- die "packaging source missing: ${PACKAGING_DIR}/polkit/network.ur.urnetwork.policy"
+ [ -f "${PACKAGING_DIR}/polkit/com.bringyour.network.policy" ] || \
+ die "packaging source missing: ${PACKAGING_DIR}/polkit/com.bringyour.network.policy"
install -d "${root}/usr/share/polkit-1/actions"
- cp "${PACKAGING_DIR}/polkit/network.ur.urnetwork.policy" \
- "${root}/usr/share/polkit-1/actions/network.ur.urnetwork.policy"
+ cp "${PACKAGING_DIR}/polkit/com.bringyour.network.policy" \
+ "${root}/usr/share/polkit-1/actions/com.bringyour.network.policy"
# Normalize modes: directories 0755; everything except the two
# executables 0644 (shared libraries ship 0644 on Debian).
@@ -191,7 +191,7 @@ assemble_daemon_root() {
# and skips the file), and the visible symptom would be "every Connect asks
# for an admin password" long after the build.
local policy_mode
- policy_mode="$(_file_mode_octal "${root}/usr/share/polkit-1/actions/network.ur.urnetwork.policy")"
+ policy_mode="$(_file_mode_octal "${root}/usr/share/polkit-1/actions/com.bringyour.network.policy")"
[ "${policy_mode}" = '644' ] || \
die "polkit action file is mode ${policy_mode}, must be 644 (polkit ignores a group- or world-writable .policy)"
}
diff --git a/packaging/make-appimage.sh b/packaging/make-appimage.sh
index a283e0d..e064097 100755
--- a/packaging/make-appimage.sh
+++ b/packaging/make-appimage.sh
@@ -188,22 +188,22 @@ done < <(find "${STAGING}/usr/share/locale" -type f -name 'urnetwork.mo' 2>/dev/
# Desktop entry: same app-id filename as the system one (wayland app_id ->
# icon association), but Exec points at the bundled binary -- inside the
# AppDir the launcher script does not exist. AppRun is what actually runs.
-DESKTOP_SRC="${APP_PACKAGING_DIR}/network.ur.urnetwork.desktop"
+DESKTOP_SRC="${APP_PACKAGING_DIR}/com.bringyour.network.desktop"
[ -f "${DESKTOP_SRC}" ] || die "missing ${DESKTOP_SRC}"
install -d "${APPDIR}/usr/share/applications"
sed -e 's/^Exec=urnetwork /Exec=urnetwork-gui /' \
-e 's/^TryExec=urnetwork$/TryExec=urnetwork-gui/' \
- "${DESKTOP_SRC}" > "${APPDIR}/usr/share/applications/network.ur.urnetwork.desktop"
-printf 'X-AppImage-Version=%s\n' "${VERSION}" >> "${APPDIR}/usr/share/applications/network.ur.urnetwork.desktop"
-cp "${APPDIR}/usr/share/applications/network.ur.urnetwork.desktop" "${APPDIR}/network.ur.urnetwork.desktop"
+ "${DESKTOP_SRC}" > "${APPDIR}/usr/share/applications/com.bringyour.network.desktop"
+printf 'X-AppImage-Version=%s\n' "${VERSION}" >> "${APPDIR}/usr/share/applications/com.bringyour.network.desktop"
+cp "${APPDIR}/usr/share/applications/com.bringyour.network.desktop" "${APPDIR}/com.bringyour.network.desktop"
install -d "${APPDIR}/usr/share/icons/hicolor/256x256/apps" "${APPDIR}/usr/share/icons/hicolor/48x48/apps"
-install -m 0644 "${APP_PACKAGING_DIR}/icons/hicolor/256x256/apps/urnetwork.png" \
- "${APPDIR}/usr/share/icons/hicolor/256x256/apps/urnetwork.png"
-install -m 0644 "${APP_PACKAGING_DIR}/icons/hicolor/48x48/apps/urnetwork.png" \
- "${APPDIR}/usr/share/icons/hicolor/48x48/apps/urnetwork.png"
-install -m 0644 "${APP_PACKAGING_DIR}/icons/hicolor/256x256/apps/urnetwork.png" "${APPDIR}/urnetwork.png"
-cp "${APPDIR}/urnetwork.png" "${APPDIR}/.DirIcon"
+install -m 0644 "${APP_PACKAGING_DIR}/icons/hicolor/256x256/apps/com.bringyour.network.png" \
+ "${APPDIR}/usr/share/icons/hicolor/256x256/apps/com.bringyour.network.png"
+install -m 0644 "${APP_PACKAGING_DIR}/icons/hicolor/48x48/apps/com.bringyour.network.png" \
+ "${APPDIR}/usr/share/icons/hicolor/48x48/apps/com.bringyour.network.png"
+install -m 0644 "${APP_PACKAGING_DIR}/icons/hicolor/256x256/apps/com.bringyour.network.png" "${APPDIR}/com.bringyour.network.png"
+cp "${APPDIR}/com.bringyour.network.png" "${APPDIR}/.DirIcon"
install -m 0755 "${SCRIPT_DIR}/appimage/AppRun" "${APPDIR}/AppRun"
diff --git a/packaging/make-arch.sh b/packaging/make-arch.sh
new file mode 100755
index 0000000..49483dd
--- /dev/null
+++ b/packaging/make-arch.sh
@@ -0,0 +1,508 @@
+#!/bin/bash
+# Build urnetwork-daemon--.pkg.tar.zst -- the ARCH-FAMILY native
+# channel (Arch, CachyOS, EndeavourOS, Manjaro), and the format this project
+# answered with a tarball because nothing else existed.
+#
+# Same shape, same input contract and same entry-point convention as
+# packaging/make-deb.sh and packaging/make-rpm.sh: called with VERSION, ARCH
+# (amd64|arm64), STAGING_DIR (the `meson install --destdir` tree) and OUT_DIR
+# in the environment. Flags override the environment for manual runs:
+# make-arch.sh [--staging ] [--arch ] [--out ] [--version ]
+#
+# WHY nfpm AND NOT A PKGBUILD: the long answer is in the header of
+# packaging/arch/nfpm.yaml. The short one is the same as the .rpm's: all three
+# native packages are assembled from ONE `meson install --destdir` staging tree
+# through packaging/lib/common.sh assemble_daemon_root(), so they cannot drift.
+# A PKGBUILD would be a fourth, independent copy of the installed-path table --
+# and it would also need an Arch machine or a container to run makepkg, which
+# neither the macOS build server nor the workflow's ubuntu:22.04 container is.
+# nfpm is pure Go down to its zstd, so this runs anywhere the .deb does.
+#
+# THREE THINGS THIS SCRIPT DOES THAT make-deb.sh DOES NOT
+# -------------------------------------------------------
+# 1. It MOVES THE UNIT to /usr/lib/systemd/system, like make-rpm.sh, but for a
+# harder reason than rpm's: on Arch /lib is a SYMLINK to usr/lib owned by
+# the `filesystem` package, and a .pkg.tar.zst carrying any member under
+# lib/ does not merely offend a guideline -- pacman ABORTS the transaction
+# with "/lib exists in filesystem (owned by filesystem)" and installs
+# nothing. It is also the path Arch's own alpm hooks watch
+# (30-systemd-daemon-reload-system.hook targets usr/lib/systemd/system/*).
+# 2. It FOLDS THE WHOLE VERSION INTO pkgver, because pacman's pkgver may not
+# contain '-' at all and nfpm silently destroys the other obvious shapes.
+# See pkg_fields(), which is a THIRD different answer from make-deb.sh's
+# last-hyphen split and make-rpm.sh's first-hyphen split.
+# 3. It PINS ONE TIMESTAMP AND VERIFIES THE .MTREE, because `pacman -Qkk` --
+# the integrity check a careful user runs against a VPN daemon -- is broken
+# by two separate nfpm behaviours and both are silent:
+# * `type: tree` writes Go's unmasked fs.FileMode (mode=20000000755) for
+# every directory, so every directory reports as altered forever. The
+# config uses a glob instead; this script asserts the fix held.
+# * the tar header mtime and .MTREE's `time=` come from different clocks,
+# so a one-second gap between assembling the root and writing the
+# archive makes every FILE report as altered. This script pins one
+# timestamp (SOURCE_DATE_EPOCH when set) and asserts every .MTREE entry
+# carries it. That is intermittent otherwise -- a build where both land
+# in the same second passes by luck -- which is exactly why it is
+# asserted rather than reviewed.
+#
+# ENVIRONMENT KNOBS (all optional)
+# UR_ARCH_CANONICAL_NAME=1 name the output the canonical pacman
+# ---.pkg.tar.zst
+# instead of the release-asset name. For building
+# a real pacman repo with repo-add; NOT for CI
+# (see "THE OUTPUT NAME").
+# UR_SIGN_KEY= as in make-deb.sh/make-rpm.sh: a DETACHED .asc
+# beside the artifact. NOTE this is NOT a pacman
+# package signature -- see the note at the bottom.
+#
+# WHAT THIS SCRIPT CANNOT TELL YOU. It builds and inspects a package; it never
+# installs one. Every function in packaging/arch/scripts/ is unexecuted until a
+# real pacman runs it. Do not read a green build here as a green install.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source-path=SCRIPTDIR
+# shellcheck source=lib/common.sh
+source "${SCRIPT_DIR}/lib/common.sh"
+
+usage() {
+ sed -n '2,11p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+ exit "${1:-0}"
+}
+
+STAGING="${STAGING_DIR:-}"
+ARCH="${ARCH:-}"
+OUT="${OUT_DIR:-}"
+VERSION="${VERSION:-${EXTERNAL_WARP_VERSION:-}}"
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --staging) STAGING="${2:?}"; shift 2 ;;
+ --arch) ARCH="${2:?}"; shift 2 ;;
+ --out) OUT="${2:?}"; shift 2 ;;
+ --version) VERSION="${2:?}"; shift 2 ;;
+ -h|--help) usage 0 ;;
+ *) die "unknown argument: $1 (see --help)" ;;
+ esac
+done
+
+[ -n "${STAGING}" ] || die "STAGING_DIR (or --staging) is required: the meson install --destdir tree"
+[ -n "${OUT}" ] || die "OUT_DIR (or --out) is required"
+[ -n "${VERSION}" ] || die "VERSION (or --version) is required"
+
+# The ASSET arch spelling stays Debian (amd64/arm64) on the way IN -- it is the
+# contract at the top of .github/workflows/beta-build.yml, every packaging
+# script here takes the same --arch values, and common.sh's check_payload_arch
+# understands only those two. The PACMAN arch spelling is separate, and it is
+# what goes in .PKGINFO and in the artifact name.
+case "${ARCH}" in
+ amd64) PKG_ARCH='x86_64' ;;
+ arm64) PKG_ARCH='aarch64' ;;
+ *) die "ARCH (or --arch) must be amd64 or arm64 (got '${ARCH:-}')" ;;
+esac
+
+command -v nfpm >/dev/null 2>&1 || die \
+ "nfpm not found -- install with: brew install nfpm (or: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest)"
+
+STAGING="$(cd "${STAGING}" && pwd)"
+mkdir -p "${OUT}"
+OUT="$(cd "${OUT}" && pwd)"
+
+# ---------------------------------------------------------------------------
+# VERSION -> pacman pkgver / pkgrel
+# ---------------------------------------------------------------------------
+# THE THIRD DIFFERENT ANSWER, AND IT HAS TO BE. The .deb's '~' lesson and the
+# .rpm's Version/Release lesson recur here a third time, and no two of the
+# three scripts derive their version fields the same way:
+#
+# make-deb.sh splits at the LAST hyphen -> 2026.8.20-1024376890-beta
+# make-rpm.sh splits at the FIRST hyphen -> 2026.8.20 + 0.1024376890.beta
+# make-arch.sh splits at NEITHER -> 2026.8.20.1024376890.beta-1
+#
+# pacman's pkgver may not contain '-' AT ALL: '-' is the pkgver/pkgrel
+# separator and pacman splits at the LAST one, rejecting anything else with
+# "package version contains invalid characters / invalid or corrupted package".
+# So the whole VERSION is folded to dots and pkgrel is pinned to the literal 1;
+# the build code lives inside pkgver, so pkgrel never has to move.
+#
+# THE TWO SHAPES THAT LOOK RIGHT AND ARE NOT (both measured on nfpm 2.47.0):
+# * version=2026.8.20, release="1024376890.beta" -- the rpm shape. nfpm
+# coerces a non-integer release to 1 SILENTLY and emits
+# `pkgver = 2026.8.20-1`. The build code and the beta marker are gone, the
+# package still builds and still installs, and every build of a given day
+# becomes the same pacman version: `pacman -U` of a newer build then says
+# "is up to date -- reinstalling" instead of upgrading. A filename that
+# lies about its own contents is worse than a build failure.
+# * the raw VERSION with version_schema: none -- emits
+# `pkgver = 2026.8.20-1024376890-beta-1`, which nfpm writes without
+# complaint and pacman refuses to install at all.
+#
+# THE ALPHABET IS rpm's MINUS '~', and that is deliberate, not an oversight:
+# * alpm does not treat '~' the dpkg way. `vercmp 1.0~beta-1 1.0-1` -> 1, so
+# on Arch '~' sorts ABOVE -- the exact inverse of the lesson recorded in
+# packaging/deb/nfpm.yaml. Carrying the trick over would import something
+# that does the opposite of what its comment says.
+# * nfpm strips '~', ':' and spaces from the FILENAME it generates while
+# writing them verbatim into .PKGINFO, so "1.0~beta" and "1.0 beta" collide
+# on one filename in a single output directory.
+#
+# ORDERING, verified with vercmp against the real pipeline shapes (alpm
+# compares each dot-separated segment numerically, so both rollovers work):
+# 2026.8.9.1020000000.beta-1 < 2026.8.20.1024376890.beta-1
+# 2026.8.20.1024351940.beta-1 < 2026.8.20.1024376890.beta-1
+# 2026.8.20.1024376890.beta-1 < 2026.9.1.1030000000.beta-1
+#
+# ONE HONEST ASYMMETRY WITH THE .rpm, written down rather than left to be
+# rediscovered: `vercmp 2026.8.20.1024376890.beta-1 2026.8.20.1024376890-1`
+# -> 1, i.e. the '.beta' suffix sorts ABOVE the same version without it, the
+# inverse of make-rpm.sh's deliberate "0."-prefixed Release. It is harmless
+# because the build code is monotonic and unique per pipeline run, so no two
+# artifacts ever differ only by that suffix. And attribute it correctly: the
+# rpm's "0." trick is unavailable because NFPM coerces the release to an
+# integer, NOT because pacman requires one -- pacman accepts a fractional
+# pkgrel (Arch's own convention allows 1.1 for a minor bump). If ordering ever
+# needs a hard reset, nfpm's archlinux packager does support `epoch:` and emits
+# it correctly; that, not a fractional pkgrel, is the escape hatch.
+pkg_fields() {
+ local v="$1" ver
+ ver="$(printf '%s' "${v}" | LC_ALL=C sed -e 's/[^0-9A-Za-z.+_]/./g' -e 's/\.\{2,\}/./g' -e 's/^\.//' -e 's/\.$//')"
+ [ -n "${ver}" ] || die "cannot derive a pacman pkgver from '${v}'"
+ printf '%s 1' "${ver}"
+}
+PKG_VERSION=''; PKG_RELEASE=''
+# `die` inside a command substitution only kills the SUBSHELL, so check the
+# result rather than trusting set -e to have stopped us (make-rpm.sh's lesson).
+read -r PKG_VERSION PKG_RELEASE <<<"$(pkg_fields "${VERSION}")" || true
+[ -n "${PKG_VERSION}" ] && [ -n "${PKG_RELEASE}" ] || \
+ die "could not derive a pacman pkgver/pkgrel from VERSION='${VERSION}'"
+case "${PKG_VERSION}" in
+ *-*) die "pkgver '${PKG_VERSION}' still contains '-' -- pacman would reject the package outright (pkgver may not contain the pkgver/pkgrel separator)" ;;
+esac
+log "version: ${VERSION} -> pacman pkgver=${PKG_VERSION} pkgrel=${PKG_RELEASE}"
+
+# ---------------------------------------------------------------------------
+# Package root
+# ---------------------------------------------------------------------------
+# ${TMPDIR%/}: macOS TMPDIR ends in '/', and the doubled slash breaks nfpm's
+# glob matching of contents[].src -- which this config depends on far more than
+# its siblings do, since the whole /usr tree is a glob here.
+TMP_BASE="${TMPDIR:-/tmp}"; TMP_BASE="${TMP_BASE%/}"
+PKGROOT="$(mktemp -d "${TMP_BASE}/urnetwork-arch-root.XXXXXX")"
+trap 'rm -rf "${PKGROOT}"' EXIT
+
+assemble_daemon_root "${STAGING}" "${PKGROOT}"
+check_payload_arch "${PKGROOT}" "${ARCH}"
+
+# --- the unit moves out of /lib, and this one is a HARD BLOCKER -------------
+# common.sh stages the unit at lib/systemd/system, which is right for dpkg and
+# fatal for pacman. Measured in a real Arch container with the unit left there:
+# error: failed to commit transaction (conflicting files)
+# urnetwork-daemon: /lib exists in filesystem (owned by filesystem)
+# and nothing was installed -- because /lib is a SYMLINK to usr/lib owned by
+# the `filesystem` package. (pacman tolerates dir-vs-dir overlap: after a
+# successful install /usr/lib/systemd/system is co-owned by a dozen packages.
+# A symlink is the case it refuses.)
+#
+# There is no YAML-level dodge: nfpm emits an implicit parent-directory member
+# for every dst, so declaring the unit as one plain file at
+# /lib/systemd/system/urnetworkd.service still produces lib/, lib/systemd/ and
+# lib/systemd/system/ members. The relocation has to happen HERE, in
+# ${PKGROOT}, before nfpm runs.
+#
+# IT MUST NOT BE FIXED IN common.sh. packaging/deb/nfpm.yaml declares
+# `src: ${PKGROOT}/lib, type: tree`, and nfpm exits 1 with "Add tree: lstat
+# .../lib: no such file or directory" the moment that directory stops being
+# staged -- so moving this into assemble_daemon_root would break the .deb.
+# Per-script remap keeps this additive, exactly as the rpm target already is.
+#
+# NOT marked config anywhere: /usr/lib/systemd/system is the vendor unit
+# directory, admin overrides live in /etc/systemd/system (drop-ins, `systemctl
+# edit`), and pacman never writes there.
+[ -f "${PKGROOT}/lib/systemd/system/urnetworkd.service" ] || \
+ die "assemble_daemon_root did not stage lib/systemd/system/urnetworkd.service -- packaging/lib/common.sh changed shape"
+install -d "${PKGROOT}/usr/lib/systemd/system"
+install -m 0644 "${PKGROOT}/lib/systemd/system/urnetworkd.service" \
+ "${PKGROOT}/usr/lib/systemd/system/urnetworkd.service"
+rm -f "${PKGROOT}/lib/systemd/system/urnetworkd.service"
+# Nothing else may be dropped on the floor. `! -type d` rather than the rpm's
+# `-type f`, because a symlink or an empty subtree common.sh might add later
+# would slip past a file-only test and be silently deleted by the rm below.
+LIB_LEFTOVERS="$(find "${PKGROOT:?}/lib" -mindepth 1 ! -type d 2>/dev/null || true)"
+[ -z "${LIB_LEFTOVERS}" ] || die "assemble_daemon_root staged files under /lib that this script does not know how to remap:
+${LIB_LEFTOVERS}
+Decide where they belong under /usr (pacman REFUSES any package member under
+/lib -- it is a symlink owned by the filesystem package) and extend the remap
+above."
+rm -rf "${PKGROOT:?}/lib"
+
+# ---------------------------------------------------------------------------
+# ONE TIMESTAMP FOR THE WHOLE PACKAGE
+# ---------------------------------------------------------------------------
+# See the `mtime:` comment in packaging/arch/nfpm.yaml for what goes wrong
+# without this: nfpm takes each tar header's mtime from when it writes the
+# archive and each .MTREE `time=` from the source file's ModTime, pacman sets
+# the installed file's mtime from the former and checks it against the latter,
+# and a one-second gap between assemble_daemon_root's copy and nfpm's write
+# makes `pacman -Qkk` report every file as altered, permanently. Pinning one
+# value collapses both fields onto it.
+#
+# SOURCE_DATE_EPOCH is honoured because it is the reproducible-builds
+# convention and because it is the only way two builds of the same input can
+# produce the same bytes here. `date -u -d @N` is GNU and `date -u -r N` is
+# BSD; this script also runs on the macOS build server, so try both rather
+# than assume (make-rpm.sh learned the same lesson about sed's '\n').
+PKG_MTIME_EPOCH="${SOURCE_DATE_EPOCH:-$(date -u +%s)}"
+case "${PKG_MTIME_EPOCH}" in
+ ''|*[!0-9]*) die "SOURCE_DATE_EPOCH must be a whole number of seconds (got '${PKG_MTIME_EPOCH}')" ;;
+esac
+PKG_MTIME="$(date -u -d "@${PKG_MTIME_EPOCH}" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \
+ || date -u -r "${PKG_MTIME_EPOCH}" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || true)"
+[ -n "${PKG_MTIME}" ] || die "neither 'date -u -d @N' (GNU) nor 'date -u -r N' (BSD) works on this host -- cannot pin the package timestamp"
+log "timestamp: ${PKG_MTIME} (epoch ${PKG_MTIME_EPOCH}${SOURCE_DATE_EPOCH:+, from SOURCE_DATE_EPOCH})"
+
+# ---------------------------------------------------------------------------
+# Render the concrete nfpm config and build
+# ---------------------------------------------------------------------------
+# nfpm does not expand environment variables in contents[].src (verified for
+# the .deb against nfpm v2.47, the version pinned in beta-build.yml), so
+# substitute here.
+NFPM_CONF="${PKGROOT}/.nfpm.yaml"
+sed -e "s|\${PKG_VERSION}|${PKG_VERSION}|g" \
+ -e "s|\${PKG_RELEASE}|${PKG_RELEASE}|g" \
+ -e "s|\${PKG_ARCH}|${PKG_ARCH}|g" \
+ -e "s|\${PKG_MTIME}|${PKG_MTIME}|g" \
+ -e "s|\${NFPM_ARCH}|${ARCH}|g" \
+ -e "s|\${PKGROOT}|${PKGROOT}|g" \
+ "${SCRIPT_DIR}/arch/nfpm.yaml" > "${NFPM_CONF}"
+
+# THE OUTPUT NAME.
+#
+# The release-asset contract (top of .github/workflows/beta-build.yml) is that
+# the FULL version string is discoverable in the asset name, because the
+# in-app checker matches assets on the version it is looking for. Like the
+# .rpm, this package keeps that information in its own punctuation and with its
+# own arch spelling:
+#
+# urnetwork-daemon-2026.8.20-1024376890-beta-x86_64.pkg.tar.zst
+#
+# `pacman -U ./file.pkg.tar.zst` does not care about the filename -- pacman
+# reads .PKGINFO, exactly as rpm reads the header -- so the asset name is free
+# to serve the checker.
+#
+# A pacman REPOSITORY is the case that does care: repo-add records the file
+# name it finds, and mirrors and humans expect the canonical
+# ---.pkg.tar.zst. UR_ARCH_CANONICAL_NAME=1
+# emits that instead. The canonical name is printed on every build either way,
+# so whoever builds the repo never has to derive it.
+CANONICAL="urnetwork-daemon-${PKG_VERSION}-${PKG_RELEASE}-${PKG_ARCH}.pkg.tar.zst"
+if [ "${UR_ARCH_CANONICAL_NAME:-0}" = 1 ]; then
+ PKG="${OUT}/${CANONICAL}"
+else
+ PKG="${OUT}/urnetwork-daemon-${VERSION}-${PKG_ARCH}.pkg.tar.zst"
+fi
+
+# cwd matters: nfpm resolves the config's relative script paths
+# (scripts/post_install, ...) against the working directory.
+(cd "${SCRIPT_DIR}/arch" && nfpm package -f "${NFPM_CONF}" -p archlinux -t "${PKG}")
+
+log "canonical pacman name (what repo-add expects): ${CANONICAL}"
+
+# ---------------------------------------------------------------------------
+# Verification
+# ---------------------------------------------------------------------------
+# UNLIKE make-deb.sh and make-rpm.sh, this does NOT need the target
+# distribution's tools. A .pkg.tar.zst is a plain zstd-compressed tar whose
+# first three members are the metadata files, so tar alone reads everything
+# that matters: .PKGINFO (the dependency and version metadata pacman actually
+# reads), .INSTALL (the six hook functions) and the payload listing. There is
+# no `pacman -Qp` equivalent needed and none available -- but there is also no
+# excuse for skipping the assertions the way the rpm path has to when rpm is
+# absent.
+#
+# The one external requirement is zstd, because nfpm's output is zstd and GNU
+# tar shells out for it. Every fallback is tried before giving up.
+PKG_TAR=''
+if tar --zstd -tf "${PKG}" >/dev/null 2>&1; then
+ PKG_TAR='tar --zstd'
+elif command -v bsdtar >/dev/null 2>&1 && bsdtar -tf "${PKG}" >/dev/null 2>&1; then
+ PKG_TAR='bsdtar'
+fi
+
+if [ -n "${PKG_TAR}" ]; then
+ WORK="$(mktemp -d "${TMP_BASE}/urnetwork-arch-verify.XXXXXX")"
+ # shellcheck disable=SC2064 # WORK is expanded now on purpose
+ trap "rm -rf '${PKGROOT}' '${WORK}'" EXIT
+ ${PKG_TAR} -xf "${PKG}" -C "${WORK}" .PKGINFO .INSTALL
+ listing="${WORK}/listing"
+ ${PKG_TAR} -tf "${PKG}" > "${listing}"
+
+ fail=0
+ log ""
+ log "--- .PKGINFO ---"
+ cat "${WORK}/.PKGINFO"
+
+ # The metadata pacman reads, asserted rather than eyeballed.
+ grep -qx "pkgname = urnetwork-daemon" "${WORK}/.PKGINFO" || { warn ".PKGINFO pkgname is not urnetwork-daemon"; fail=1; }
+ grep -qx "pkgver = ${PKG_VERSION}-${PKG_RELEASE}" "${WORK}/.PKGINFO" || {
+ warn ".PKGINFO pkgver is not '${PKG_VERSION}-${PKG_RELEASE}' -- nfpm re-parsed the version (is version_schema: none still set?)"
+ fail=1; }
+ grep -qx "arch = ${PKG_ARCH}" "${WORK}/.PKGINFO" || { warn ".PKGINFO arch is not ${PKG_ARCH}"; fail=1; }
+ # `maintainer:` does NOT feed this field; only archlinux.packager does, and
+ # losing it prints "Unknown Packager" in every `pacman -Qi`.
+ grep -q '^packager = URnetwork' "${WORK}/.PKGINFO" || {
+ warn ".PKGINFO packager is not set (archlinux.packager missing from the config -- 'Unknown Packager')"; fail=1; }
+ # The three conffiles must be pacman `backup =` entries, or an admin's edits
+ # are overwritten on every upgrade instead of landing as .pacnew.
+ for want in etc/urnetwork/autostart/com.bringyour.network.desktop \
+ etc/NetworkManager/conf.d/95-urnetwork.conf \
+ etc/udev/rules.d/85-urnetwork-unmanaged.rules ; do
+ grep -qx "backup = ${want}" "${WORK}/.PKGINFO" || { warn "${want} is not marked 'backup =' -- an upgrade would overwrite an admin's edits"; fail=1; }
+ done
+ # The dependencies that are the whole reason to ship a native package.
+ for want in iproute2 nftables systemd shadow fuse2; do
+ grep -qx "depend = ${want}" "${WORK}/.PKGINFO" || { warn "missing dependency: ${want}"; fail=1; }
+ done
+ # Debian syntax passes through nfpm VERBATIM and unvalidated, producing a
+ # package that builds cleanly and can never be installed. Catch it here.
+ if grep -E '^depend = ' "${WORK}/.PKGINFO" | grep -qE '\||\(|\)'; then
+ warn "a dependency uses Debian alternation ('a | b') or Debian version syntax ('(>= x)') -- pacman resolves those as literal package names and the package becomes uninstallable:"
+ grep -E '^depend = ' "${WORK}/.PKGINFO" | grep -E '\||\(|\)' >&2
+ fail=1
+ fi
+
+ log ""
+ log "--- .INSTALL hooks ---"
+ # ALL SIX, and the two upgrade hooks are the ones that go missing silently.
+ # nfpm's generic `scripts:` maps ONLY to the four install/remove functions;
+ # pre_upgrade/post_upgrade come from the separate archlinux.scripts block,
+ # and without them every `pacman -U` upgrade, every downgrade and every
+ # reinstall runs NOTHING AT ALL while pacman reports success.
+ for fn in pre_install post_install pre_upgrade post_upgrade pre_remove post_remove; do
+ if grep -qE "^[[:space:]]*(function[[:space:]]+)?${fn}[[:space:]]*\(\)" "${WORK}/.INSTALL"; then
+ log " ${fn}: present"
+ else
+ warn " ${fn}: MISSING from .INSTALL"
+ # A plain `[ a ] || [ b ] && warn` here would be a set -e landmine:
+ # when neither test matches, the compound's status is 1 and the
+ # script would exit before reporting the other five hooks.
+ case "${fn}" in
+ pre_upgrade|post_upgrade)
+ warn " (declare it under archlinux.scripts in packaging/arch/nfpm.yaml -- the top-level scripts: block cannot emit it)" ;;
+ esac
+ fail=1
+ fi
+ done
+ # The deb/rpm argument conventions do not exist here. A `case "$1" in
+ # configure)` or a `[ "$1" -eq 1 ]` copied from a sibling script never
+ # matches anything pacman passes, and the package then installs with exit
+ # code 0 having done nothing -- the exact silent failure that makes this
+ # worth asserting on the built artifact rather than trusting a review.
+ #
+ # COMMENT LINES ARE STRIPPED FIRST, and that is not fussiness: nfpm inlines
+ # each script file VERBATIM into its function, comments included, and
+ # packaging/arch/scripts/ discusses both wrong conventions at length in
+ # order to warn the next reader off them. Grepping the raw .INSTALL made
+ # this check fire on its own documentation.
+ _code="${WORK}/.INSTALL.code"
+ grep -v '^[[:space:]]*#' "${WORK}/.INSTALL" > "${_code}" || true
+ if grep -qE 'configure\)|"\$1" = "(remove|purge|configure)"' "${_code}"; then
+ warn ".INSTALL branches on dpkg's 'configure'/'remove'/'purge' -- pacman passes version strings and that branch can never match"
+ fail=1
+ fi
+ if grep -qE '\[ *"?\$\{?1[:-]?[^ ]*"? *-(eq|ge|le|gt|lt) *[0-9]' "${_code}"; then
+ warn ".INSTALL compares \$1 numerically like an rpm scriptlet -- pacman passes version strings, not 0/1/2"
+ fail=1
+ fi
+
+ log ""
+ log "--- payload ($(grep -cv '/$' "${listing}") file entries) ---"
+ head -25 "${listing}"
+
+ # The files whose absence is silent and fatal. A file count alone passes a
+ # package that is all locale catalogs and no daemon -- the shape of failure
+ # the Windows MSI shipped for months.
+ for want in \
+ usr/lib/urnetwork/urnetworkd \
+ usr/lib/urnetwork/libURnetworkSdk.so \
+ usr/bin/urnetwork \
+ usr/lib/systemd/system/urnetworkd.service \
+ usr/share/polkit-1/actions/com.bringyour.network.policy \
+ usr/share/urnetwork/world-110m.json ; do
+ grep -Fxq "${want}" "${listing}" || { warn "MISSING from the payload: ${want}"; fail=1; }
+ done
+ # THE HARD BLOCKER, asserted in the built artifact rather than trusted from
+ # the remap above. Widened past /lib on purpose: /bin, /sbin and /lib64 are
+ # every one of them filesystem-owned symlinks on Arch and would abort the
+ # transaction in exactly the same way.
+ if grep -Eq '^(bin|sbin|lib|lib64)/' "${listing}"; then
+ warn "the package ships members under a filesystem-owned symlink (/bin, /sbin, /lib or /lib64). pacman ABORTS the whole transaction with 'exists in filesystem (owned by filesystem)' and installs nothing:"
+ grep -E '^(bin|sbin|lib|lib64)/' "${listing}" >&2
+ fail=1
+ fi
+
+ # THE .MTREE DIRECTORY MODES. nfpm's `type: tree` writes Go's unmasked
+ # fs.FileMode into .MTREE -- mode=20000000755, i.e. fs.ModeDir|0755
+ # formatted with %o and never masked to permission bits. The visible
+ # results are one "warning: directory permissions differ on /
+ # filesystem: 755 package: 755" per directory at install time (both sides
+ # printing 755 is the tell) and `pacman -Qkk urnetwork-daemon` reporting
+ # those directories as altered FOREVER -- which, for a VPN daemon, is
+ # exactly the check a careful user runs. The config avoids it with a glob
+ # instead of a tree; assert that it stayed that way.
+ ${PKG_TAR} -xf "${PKG}" -C "${WORK}" .MTREE 2>/dev/null || true
+ if [ -f "${WORK}/.MTREE" ]; then
+ folded="$(gzip -cd "${WORK}/.MTREE" 2>/dev/null | grep -c 'mode=2[0-9]\{10\}' || true)"
+ if [ "${folded:-0}" -gt 0 ]; then
+ warn ".MTREE carries ${folded} directory entries with an unmasked Go file mode (mode=20000000755)."
+ warn "Every one of them makes pacman print a bogus 'directory permissions differ' warning at install"
+ warn "time and shows up as an altered file under 'pacman -Qkk' forever. This is what nfpm's"
+ warn "'type: tree' does; packaging/arch/nfpm.yaml must use the '\${PKGROOT}/usr/**/*' glob instead."
+ warn "Do NOT 'fix' it with file_info.mode on the tree entry -- that clobbers every FILE to 0755 too."
+ fail=1
+ else
+ log "mtree: no unmasked directory modes"
+ fi
+ # THE OTHER HALF OF `pacman -Qkk`, and the half that is INTERMITTENT --
+ # which is why it is asserted rather than reviewed. Every .MTREE entry
+ # must carry the one pinned timestamp; if any carries the source file's
+ # own ModTime instead, nfpm ignored `mtime:` and pacman will report
+ # every file as "Modification time mismatch" on every installed
+ # machine. A build where the copy and the archive write happen to land
+ # in the same second passes by luck, so a spot check would not do.
+ stray="$(gzip -cd "${WORK}/.MTREE" 2>/dev/null | grep -c "time=" || true)"
+ pinned="$(gzip -cd "${WORK}/.MTREE" 2>/dev/null | grep -c "time=${PKG_MTIME_EPOCH}\." || true)"
+ if [ "${stray:-0}" -ne "${pinned:-0}" ]; then
+ warn ".MTREE has ${stray} timestamped entries but only ${pinned} carry the pinned time=${PKG_MTIME_EPOCH}."
+ warn "nfpm did not honour 'mtime:' for all of them. pacman sets each file's mtime from the TAR"
+ warn "header and checks it against .MTREE, so every mismatched entry becomes a permanent"
+ warn "'Modification time mismatch' under 'pacman -Qkk urnetwork-daemon'."
+ fail=1
+ else
+ log "mtree: all ${pinned} timestamps pinned to ${PKG_MTIME_EPOCH} (pacman -Qkk will be clean)"
+ fi
+ fi
+
+ log ""
+ [ "${fail}" = 0 ] || die "package verification failed (see the warnings above)"
+ log "package verified."
+else
+ warn "cannot read ${PKG##*/}: no zstd-capable tar on this host (install zstd, or bsdtar)."
+ warn "The package WAS built -- nfpm's zstd is pure Go -- but every payload, dependency and"
+ warn "hook assertion above was skipped. Do not ship an unverified build from a host like this."
+fi
+
+log ""
+log "NOT executed by this script, and only a real pacman can: the six functions in"
+log "packaging/arch/scripts/. A green build here is not a green install."
+
+sha256_file "${PKG}"
+maybe_sign "${PKG}"
+# Said explicitly because the word "signed" means something different here than
+# it does for the .deb. UR_SIGN_KEY produces a DETACHED .asc beside the file,
+# which proves authorship to a human. It is NOT a pacman package signature: a
+# pacman repo with SigLevel=Required wants a detached .sig made with the
+# repo's key and listed in the database, and `pacman -U` of a local file
+# ignores the .asc entirely.
+if [ -z "${UR_SIGN_KEY:-}" ]; then
+ log "not signed. Note that even with UR_SIGN_KEY this produces a detached .asc, NOT the .sig a pacman repo with SigLevel=Required requires."
+fi
+log "built: ${PKG}"
diff --git a/packaging/make-flatpak.sh b/packaging/make-flatpak.sh
index c92da22..722431a 100755
--- a/packaging/make-flatpak.sh
+++ b/packaging/make-flatpak.sh
@@ -5,7 +5,7 @@
# /dev/net/tun, no CAP_NET_ADMIN and no way to install a system unit. The app
# reaches the HOST's urnetworkd over /run/urnetwork/control.sock, which the
# manifest exposes read-only (Trayscale's pattern). Install the daemon from the
-# native .deb/.rpm — see packaging/flatpak/network.ur.urnetwork.yml for the
+# native .deb/.rpm — see packaging/flatpak/com.bringyour.network.yml for the
# full reasoning.
#
# Works on an immutable host (Bazzite/Silverblue): flatpak-builder itself runs
@@ -20,8 +20,8 @@
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-MANIFEST="${MANIFEST:-$REPO_ROOT/packaging/flatpak/network.ur.urnetwork.yml}"
-APP_ID="network.ur.urnetwork"
+MANIFEST="${MANIFEST:-$REPO_ROOT/packaging/flatpak/com.bringyour.network.yml}"
+APP_ID="com.bringyour.network"
RUNTIME_VERSION="${RUNTIME_VERSION:-49}"
# The build dir MUST live inside the repo. flatpak-builder runs sandboxed and
# gets a PRIVATE /tmp, so a build dir under the host's /tmp vanishes between
diff --git a/packaging/polkit/network.ur.urnetwork.policy b/packaging/polkit/com.bringyour.network.policy
similarity index 95%
rename from packaging/polkit/network.ur.urnetwork.policy
rename to packaging/polkit/com.bringyour.network.policy
index b33e2ba..d6c0c5f 100644
--- a/packaging/polkit/network.ur.urnetwork.policy
+++ b/packaging/polkit/com.bringyour.network.policy
@@ -2,7 +2,7 @@
-
+
Connect or disconnect the URnetwork VPN
Authentication is required to route this device's traffic through URnetwork.
@@ -81,7 +81,7 @@
a site can require a password for THIS and not for Connect. The kill
switch can cut every user on the box off the network, which is why it is
worth its own knob. -->
-
+
Change the URnetwork kill switch
Authentication is required to change whether this device's traffic is blocked when the URnetwork connection drops.
@@ -95,7 +95,7 @@
DIFFERENT uid started. This is the action that replaces "everyone in the urnetwork
group is interchangeable": any active local user may run their own
tunnel, nobody takes over someone else's without proving admin. -->
-
+
Take over a URnetwork connection started by another user
Authentication is required to take over the URnetwork connection another user started on this device.
@@ -111,7 +111,7 @@
own. Same default trust as control-tunnel (if you can silently start a
tunnel you can read its log), separate id so it can be tightened alone.
The daemon ALSO refuses it across uids: see kCodeAuthNotTunnelOwner. -->
-
+
Read the URnetwork service log
Authentication is required to read the URnetwork service log.
diff --git a/packaging/rpm/nfpm.yaml b/packaging/rpm/nfpm.yaml
index 1e43b3b..4ba2282 100644
--- a/packaging/rpm/nfpm.yaml
+++ b/packaging/rpm/nfpm.yaml
@@ -153,8 +153,8 @@ contents:
# Admin-editable integration files. %config(noreplace) is the rpm equivalent
# of a dpkg conffile: an edited file is preserved and the package's version
# lands beside it as .rpmnew.
- - src: "${PKGROOT}/etc/urnetwork/autostart/network.ur.urnetwork.desktop"
- dst: /etc/urnetwork/autostart/network.ur.urnetwork.desktop
+ - src: "${PKGROOT}/etc/urnetwork/autostart/com.bringyour.network.desktop"
+ dst: /etc/urnetwork/autostart/com.bringyour.network.desktop
type: "config|noreplace"
- src: "${PKGROOT}/etc/NetworkManager/conf.d/95-urnetwork.conf"
dst: /etc/NetworkManager/conf.d/95-urnetwork.conf
diff --git a/packaging/tarball/install.sh b/packaging/tarball/install.sh
index 8b7ec98..6d855e0 100755
--- a/packaging/tarball/install.sh
+++ b/packaging/tarball/install.sh
@@ -162,7 +162,7 @@ DNS_WARNING=''
# legacy `urnetwork` group check stays in force.
POLKIT_PRESENT=0
POLKIT_VERSION=''
-POLICY_REL='/usr/share/polkit-1/actions/network.ur.urnetwork.policy'
+POLICY_REL='/usr/share/polkit-1/actions/com.bringyour.network.policy'
log() { printf '%s\n' "$*"; }
note() { printf -- '- %s\n' "$*"; }
@@ -552,17 +552,22 @@ fi
if command -v rpm >/dev/null 2>&1 && rpm -q "${PKG_NAME}" >/dev/null 2>&1; then
die "${PKG_NAME} is installed and owned by rpm -- use dnf/zypper to upgrade or remove it first"
fi
-# pacman (Arch, CachyOS, Manjaro, EndeavourOS). There is no URnetwork package
-# in the Arch repositories or the AUR today, so this is a guard against a
-# FUTURE one rather than a live conflict -- and the day one lands, two owners
-# of the same paths is exactly the silent-corruption failure dpkg is refused
-# for above.
+# pacman (Arch, CachyOS, Manjaro, EndeavourOS). THIS IS NOW A LIVE CONFLICT,
+# not the hypothetical it was written as: packaging/make-arch.sh ships a real
+# urnetwork-daemon .pkg.tar.zst, owning exactly the paths below. Two owners of
+# the same paths is the same silent-corruption failure dpkg is refused for
+# above -- pacman would not know these files changed, and the next
+# `pacman -Syu` or `pacman -R` would half-replace or half-remove the install.
#
-# Queried by PATH, not by name. An AUR package could be called urnetwork,
-# urnetwork-bin, urnetwork-daemon or urnetwork-git, and a name check would miss
-# all but one; the path is the thing that actually collides. `pacman -Qo` exits
-# non-zero and writes to stderr both when nothing owns the file and when the
-# file does not exist, so both are absorbed.
+# SteamOS is the one Arch-family host where the tarball is the RIGHT answer and
+# this guard should stay quiet: its /usr is read-only, so the pacman package is
+# never installed there and pacman owns none of these paths.
+#
+# Queried by PATH, not by name. Our own package is urnetwork-daemon, but an AUR
+# package could equally be urnetwork, urnetwork-bin or urnetwork-git, and a
+# name check would miss all but one; the path is the thing that actually
+# collides. `pacman -Qo` exits non-zero and writes to stderr both when nothing
+# owns the file and when the file does not exist, so both are absorbed.
if command -v pacman >/dev/null 2>&1; then
PACMAN_OWNER=''
PACMAN_OWNED_PATH=''
@@ -1339,7 +1344,7 @@ if [ "${POLKIT_PRESENT}" = 1 ]; then
#
# MEASURED on Bazzite: after a clean install the daemon correctly
# reported `authorization: polkit`, and `pkaction --action-id
- # network.ur.urnetwork.control-tunnel` answered "No action with action
+ # com.bringyour.network.control-tunnel` answered "No action with action
# id" -- polkitd had been up since the previous boot, two days earlier.
# Every authorization check would have been made against an action
# polkit did not know, so Connect would have failed on a host the
diff --git a/packaging/tarball/uninstall.sh b/packaging/tarball/uninstall.sh
index 34a32f6..79c71d0 100755
--- a/packaging/tarball/uninstall.sh
+++ b/packaging/tarball/uninstall.sh
@@ -109,11 +109,19 @@ fi
if command -v rpm >/dev/null 2>&1 && rpm -q "${PKG_NAME}" >/dev/null 2>&1; then
die "${PKG_NAME} is owned by rpm -- remove it with: sudo dnf remove ${PKG_NAME}"
fi
-# ...and never remove a pacman-owned one either. Queried by PATH rather than by
-# name for the same reason install.sh does it: a future Arch/AUR package could
-# be called urnetwork, urnetwork-bin, urnetwork-daemon or urnetwork-git, and
-# the file is what actually collides. `pacman -Qo` exits non-zero both when
-# nothing owns the path and when the path does not exist.
+# ...and never remove a pacman-owned one either -- which stopped being
+# hypothetical when packaging/make-arch.sh landed: releases now carry a real
+# urnetwork-daemon .pkg.tar.zst owning these exact paths, and the right removal
+# for it is `pacman -R`, which runs the package's own pre_remove hook (stop and
+# disable the unit WHILE the binary still exists, so its ExecStopPost can tear
+# the nftables ruleset down). This script would delete the binary out from
+# under pacman instead.
+#
+# Queried by PATH rather than by name for the same reason install.sh does it:
+# our own package is urnetwork-daemon, but an AUR one could be urnetwork,
+# urnetwork-bin or urnetwork-git, and the file is what actually collides.
+# `pacman -Qo` exits non-zero both when nothing owns the path and when the path
+# does not exist.
if command -v pacman >/dev/null 2>&1 && [ -z "${PREFIX}" ]; then
for _p in "${LIB_DIR}/urnetworkd" "${BIN_DIR}/urnetwork" \
/usr/lib/systemd/system/"${UNIT}" /lib/systemd/system/"${UNIT}"; do
@@ -152,12 +160,12 @@ ${BIN_DIR}/urnetwork
/lib/systemd/system/urnetworkd.service
/usr/lib/systemd/system/urnetworkd.service
/etc/systemd/system/urnetworkd.service
-${SHARE_DIR}/applications/network.ur.urnetwork.desktop
-${SHARE_DIR}/metainfo/network.ur.urnetwork.metainfo.xml
-${SHARE_DIR}/icons/hicolor/48x48/apps/urnetwork.png
-${SHARE_DIR}/icons/hicolor/256x256/apps/urnetwork.png
-${SHARE_DIR}/polkit-1/actions/network.ur.urnetwork.policy
-/etc/urnetwork/autostart/network.ur.urnetwork.desktop
+${SHARE_DIR}/applications/com.bringyour.network.desktop
+${SHARE_DIR}/metainfo/com.bringyour.network.metainfo.xml
+${SHARE_DIR}/icons/hicolor/48x48/apps/com.bringyour.network.png
+${SHARE_DIR}/icons/hicolor/256x256/apps/com.bringyour.network.png
+${SHARE_DIR}/polkit-1/actions/com.bringyour.network.policy
+/etc/urnetwork/autostart/com.bringyour.network.desktop
/etc/NetworkManager/conf.d/95-urnetwork.conf
/etc/udev/rules.d/85-urnetwork-unmanaged.rules"
# plus whole directories swept below: ${SHARE_DIR}/urnetwork, locale .mo