Skip to content

Commit 59646e6

Browse files
authored
daemon: forward GUI envs; use user XDG config; (#333)
- Forward essential X/Wayland env vars from client to daemon-launched apps: DISPLAY, WAYLAND_DISPLAY, XAUTHORITY, XDG_RUNTIME_DIR, XDG_CONFIG_HOME, XDG_SESSION_TYPE, MOZ_ENABLE_WAYLAND, QT_QPA_PLATFORM, GTK_MODULES, GTK3_MODULES, I3SOCK. Default XDG_RUNTIME_DIR to /run/user/<uid> when missing. - Extend DaemonRequest to carry the forwarded env map; merge into child env in daemon path. - Respect XDG_CONFIG_HOME in daemon mode: add thread-local config_dir override and set it per client to forwarded XDG_CONFIG_HOME (if it exists) or fall back to ~/.config of the connecting user. Clear the override when the handler exits. This fixes false “config files … do not exist” when the daemon runs under systemd. - Demote noisy “Could not get PULSE_SERVER from host” to debug in daemon mode (still warn in non-daemon). Daemon continues to set PULSE_SERVER to unix:/run/user/<uid>/pulse/native for the connecting user. - Add daemon mode flag in core (set_daemon_mode/is_daemon_mode); mark daemon context in main and use it for conditional logging. - Avoid interactive auto-sync in daemon mode: setup_namespace now accepts auto_sync_if_missing; daemon passes false (bails with a clear message if configs are missing), CLI path passes true (preserving previous behavior). - Pass env and stdio consistently for both TTY and non-TTY client cases. Result: - GUI apps launched via the daemon get correct X/Wayland/desktop env and no longer fail with “no DISPLAY”. - Daemon resolves configs from the user’s XDG config dir instead of /root/.config under systemd. - Systemd logs are quieter; no spurious PULSE warnings or “not a terminal” from attempted interactive sync.
1 parent 64798ff commit 59646e6

File tree

11 files changed

+161
-30
lines changed

11 files changed

+161
-30
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "vopono"
33
description = "Launch applications via VPN tunnels using temporary network namespaces"
4-
version = "0.10.15"
4+
version = "0.10.16"
55
authors = ["James McMurray <[email protected]>"]
66
edition = "2024"
77
license = "GPL-3.0-or-later"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ For a smoother experience, run the privileged root daemon and keep using `vopono
9090
- Or run manually as root: `sudo vopono daemon`
9191
- Then use vopono normally as your user: `vopono exec --provider mullvad --server se firefox`
9292

93-
See USERGUIDE.md for a ready‑to‑copy systemd unit.
93+
See [USERGUIDE.md](USERGUIDE.md) for a ready‑to‑copy systemd unit.
9494

9595
vopono can handle up to 255 separate network namespaces (i.e. different VPN server
9696
connections - if your VPN provider allows it). Commands launched with

USERGUIDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ vopono now supports a persistent root daemon that handles all privileged work. R
3131

3232
Signals (e.g., Ctrl+C, Ctrl+Z) and interactive TTY behavior work cleanly via the daemon. The daemon listens on `/run/vopono.sock` and cleans it up on exit.
3333

34-
Example systemd unit for the root daemon (`/etc/systemd/system/vopono-daemon.service`):
34+
Example systemd unit for the root daemon (`/etc/systemd/system/vopono.service`):
3535

3636
```
3737
[Unit]
@@ -54,8 +54,8 @@ WantedBy=multi-user.target
5454
Check status and logs:
5555

5656
```
57-
sudo systemctl status vopono-daemon
58-
sudo journalctl -u vopono-daemon -e
57+
sudo systemctl status vopono
58+
sudo journalctl -u vopono -e
5959
```
6060

6161
Note there is a known issue that when using tmux, etc. - sometimes the

src/daemon.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ use std::thread;
3333

3434
#[derive(Serialize, Deserialize, Debug)]
3535
pub enum DaemonRequest {
36-
Execute(ExecCommand),
36+
Execute {
37+
cmd: ExecCommand,
38+
env: std::collections::HashMap<String, String>,
39+
},
3740
Control(DaemonControl),
3841
}
3942

@@ -118,6 +121,8 @@ fn handle_client(mut conn: LocalSocketStream) -> anyhow::Result<()> {
118121
user.name, uid, group.name, gid
119122
);
120123

124+
// Note: Do not set config override yet; we may adopt client's XDG_CONFIG_HOME.
125+
121126
// Read a framed request (length-prefixed u32 then payload)
122127
let mut len_bytes = [0u8; 4];
123128
conn.read_exact(&mut len_bytes)?;
@@ -132,7 +137,19 @@ fn handle_client(mut conn: LocalSocketStream) -> anyhow::Result<()> {
132137
recv_fds_over_unix_socket(&conn, 3)?;
133138

134139
match request {
135-
DaemonRequest::Execute(mut exec_command) => {
140+
DaemonRequest::Execute {
141+
cmd: mut exec_command,
142+
env: forwarded_env,
143+
} => {
144+
// Set config override from client's XDG_CONFIG_HOME if present, falling back to ~/.config
145+
let override_base = forwarded_env
146+
.get("XDG_CONFIG_HOME")
147+
.and_then(|p| {
148+
let pb = std::path::PathBuf::from(p);
149+
if pb.exists() { Some(pb) } else { None }
150+
})
151+
.unwrap_or_else(|| user.dir.join(".config"));
152+
vopono_core::util::set_config_dir_override(Some(override_base));
136153
exec_command.user = Some(user.name);
137154
exec_command.group = Some(group.name);
138155

@@ -158,6 +175,7 @@ fn handle_client(mut conn: LocalSocketStream) -> anyhow::Result<()> {
158175
false,
159176
Some((slave, slave, slave)),
160177
true,
178+
Some(forwarded_env.clone()),
161179
)?;
162180
// Do not close the slave here: it's owned by the spawned child via Stdio::from_raw_fd
163181
// and will be closed by the child/OS when appropriate.
@@ -168,6 +186,7 @@ fn handle_client(mut conn: LocalSocketStream) -> anyhow::Result<()> {
168186
false,
169187
Some((client_stdin_fd, client_stdout_fd, client_stderr_fd)),
170188
false,
189+
Some(forwarded_env.clone()),
171190
)?;
172191
}
173192

@@ -356,6 +375,8 @@ fn handle_client(mut conn: LocalSocketStream) -> anyhow::Result<()> {
356375
// Ignore unexpected control frame sent as the first message
357376
}
358377
}
378+
// Clear any thread-local override before exiting the handler
379+
vopono_core::util::set_config_dir_override(None);
359380
Ok(())
360381
}
361382

src/exec.rs

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ pub fn execute_as_daemon(
5050
parsed_command,
5151
forwarder,
5252
host_env_vars,
53-
} = setup_namespace(command, &uiclient, true)?;
53+
} = setup_namespace(command, &uiclient, true, false)?; // daemon: do not auto-sync
5454

5555
let ns = ns.write_lockfile(&parsed_command.application)?;
5656

@@ -76,14 +76,15 @@ pub fn execute_as_daemon_with_stdio(
7676
pipe_io: bool,
7777
stdio_fds: Option<(RawFd, RawFd, RawFd)>,
7878
take_controlling_tty: bool,
79+
forwarded_env: Option<std::collections::HashMap<String, String>>,
7980
) -> anyhow::Result<(ApplicationWrapper, NetworkNamespace)> {
8081
let uiclient = CliClient {};
8182
let NamespaceConfig {
8283
ns,
8384
parsed_command,
8485
forwarder,
8586
host_env_vars,
86-
} = setup_namespace(command, &uiclient, true)?;
87+
} = setup_namespace(command, &uiclient, true, false)?; // daemon: do not auto-sync
8788

8889
// In daemon mode, ensure PULSE_SERVER points to the connecting user's runtime
8990
// so apps can talk to the host Pulse/pipewire server.
@@ -93,6 +94,18 @@ pub fn execute_as_daemon_with_stdio(
9394
{
9495
let pulse = format!("unix:/run/user/{}/pulse/native", user.uid.as_raw());
9596
host_env_vars.insert("PULSE_SERVER".to_string(), pulse);
97+
// Ensure XDG_RUNTIME_DIR points at the connecting user's runtime dir
98+
host_env_vars
99+
.entry("XDG_RUNTIME_DIR".to_string())
100+
.or_insert_with(|| format!("/run/user/{}", user.uid.as_raw()));
101+
}
102+
103+
// Merge all client-forwarded environment variables. Client side already whitelists
104+
// which keys are forwarded; here we simply apply them.
105+
if let Some(fwd) = forwarded_env.as_ref() {
106+
for (k, v) in fwd {
107+
host_env_vars.insert(k.clone(), v.clone());
108+
}
96109
}
97110

98111
let ns = ns.write_lockfile(&parsed_command.application)?;
@@ -125,7 +138,7 @@ pub fn exec(
125138
parsed_command,
126139
forwarder,
127140
host_env_vars,
128-
} = setup_namespace(command, uiclient, verbose)?;
141+
} = setup_namespace(command, uiclient, verbose, true)?; // CLI path: allow auto-sync if missing
129142
let ns = ns.write_lockfile(&parsed_command.application)?;
130143
run_application_and_wait(
131144
&parsed_command,
@@ -142,6 +155,7 @@ fn setup_namespace(
142155
command: ExecCommand,
143156
uiclient: &dyn UiClient,
144157
verbose: bool,
158+
auto_sync_if_missing: bool,
145159
) -> anyhow::Result<NamespaceConfig> {
146160
create_dir_all(vopono_dir()?)?;
147161
let vopono_config_settings = ArgsConfig::get_config_file(&command)?;
@@ -164,16 +178,28 @@ fn setup_namespace(
164178
.wireguard_dir(),
165179
_ => unreachable!(),
166180
}?;
167-
if !cdir.exists() || cdir.read_dir()?.next().is_none() {
168-
info!(
169-
"Config files for {} {} do not exist, running vopono sync",
170-
parsed_command.provider, parsed_command.protocol
171-
);
172-
synch(
173-
parsed_command.provider.clone(),
174-
&Some(parsed_command.protocol.clone()),
175-
uiclient,
176-
)?;
181+
let missing_configs = !cdir.exists() || cdir.read_dir()?.next().is_none();
182+
if missing_configs {
183+
if auto_sync_if_missing {
184+
info!(
185+
"Config files for {} {} do not exist, running vopono sync",
186+
parsed_command.provider, parsed_command.protocol
187+
);
188+
synch(
189+
parsed_command.provider.clone(),
190+
&Some(parsed_command.protocol.clone()),
191+
uiclient,
192+
)?;
193+
} else {
194+
// In daemon mode, avoid interactive sync and return a clear error.
195+
anyhow::bail!(
196+
"Missing configuration for {} {}. Run 'vopono sync --provider {} --protocol {}' as your user to initialize.",
197+
parsed_command.provider,
198+
parsed_command.protocol,
199+
parsed_command.provider,
200+
parsed_command.protocol
201+
);
202+
}
177203
}
178204
}
179205

src/main.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ fn main() -> anyhow::Result<()> {
6161
eprintln!("Error: The daemon command requires root privileges.");
6262
std::process::exit(1);
6363
}
64+
// Mark process context so libraries can adjust behavior (e.g., logging verbosity)
65+
vopono_core::util::set_daemon_mode(true);
6466
info!("Starting vopono in daemon mode.");
6567
return daemon::start();
6668
}
@@ -116,7 +118,34 @@ fn forward_to_daemon(cmd: &ExecCommand) -> anyhow::Result<i32> {
116118
};
117119

118120
debug!("Connected to daemon, forwarding command.");
119-
let request = daemon::DaemonRequest::Execute(cmd.clone());
121+
// Collect a small set of environment variables from the client session
122+
// that are relevant for GUI/desktop integration.
123+
let mut fwd_env: std::collections::HashMap<String, String> = Default::default();
124+
for key in [
125+
// X/Wayland basics
126+
"DISPLAY",
127+
"WAYLAND_DISPLAY",
128+
"XAUTHORITY",
129+
// Runtime/config roots for per-user sockets and configs
130+
"XDG_RUNTIME_DIR",
131+
"XDG_CONFIG_HOME",
132+
// Toolkit/session hints (safe to forward)
133+
"XDG_SESSION_TYPE",
134+
"MOZ_ENABLE_WAYLAND",
135+
"QT_QPA_PLATFORM",
136+
"GTK_MODULES",
137+
"GTK3_MODULES",
138+
// Window manager IPC socket
139+
"I3SOCK",
140+
] {
141+
if let Ok(val) = std::env::var(key) {
142+
fwd_env.insert(key.to_string(), val);
143+
}
144+
}
145+
let request = daemon::DaemonRequest::Execute {
146+
cmd: cmd.clone(),
147+
env: fwd_env,
148+
};
120149
let bytes = bincode::serde::encode_to_vec(&request, bincode::config::standard())?;
121150
conn.write_all(&(bytes.len() as u32).to_be_bytes())?;
122151
conn.write_all(&bytes)?;

vopono.service

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[Unit]
2+
Description=Vopono root daemon
3+
After=network.target
4+
Requires=network.target
5+
6+
[Service]
7+
Type=simple
8+
ExecStart=/usr/bin/vopono daemon
9+
Restart=on-failure
10+
RestartSec=2s
11+
# Optional: enable structured logs
12+
Environment=RUST_LOG=info
13+
14+
[Install]
15+
WantedBy=multi-user.target

vopono_core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "vopono_core"
33
description = "Library code for running VPN connections in network namespaces"
4-
version = "0.1.15"
4+
version = "0.1.16"
55
edition = "2024"
66
authors = ["James McMurray <[email protected]>"]
77
license = "GPL-3.0-or-later"

vopono_core/src/network/application_wrapper.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -261,13 +261,14 @@ impl ApplicationWrapper {
261261
// If stdin is a TTY, take it as controlling terminal
262262
// Use TIOCSCTTY with arg 1 to forcibly acquire if already in use
263263
let fd0: i32 = 0;
264-
let is_tty = libc::isatty(fd0) == 1;
265-
if is_tty {
266-
// Let the compiler infer the proper ioctl request type per target
267-
let _ = libc::ioctl(fd0, libc::TIOCSCTTY as _, 1);
268-
// Set foreground process group to our own pgrp
269-
let pgrp = libc::getpgrp();
270-
let _ = libc::tcsetpgrp(fd0, pgrp);
264+
if libc::isatty(fd0) == 1 {
265+
// Attempt to acquire the TTY as controlling terminal. Only set
266+
// the foreground process group if that succeeded.
267+
let acquire_res = libc::ioctl(fd0, libc::TIOCSCTTY as _, 1);
268+
if acquire_res == 0 {
269+
let pgrp = libc::getpgrp();
270+
let _ = libc::tcsetpgrp(fd0, pgrp);
271+
}
271272
}
272273
}
273274

vopono_core/src/util/env_vars.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,21 @@ use crate::network::{netns::NetworkNamespace, port_forwarding::Forwarder};
88
pub fn get_host_env_vars() -> HashMap<String, String> {
99
let mut env_vars = HashMap::new();
1010

11+
// Best-effort: try to detect Pulse/pipewire server when available.
12+
// Avoid noisy warnings in non-interactive contexts (e.g., systemd daemon) by logging at debug level.
1113
if which::which("pactl").is_ok() {
1214
match crate::util::pulseaudio::get_pulseaudio_server() {
1315
Ok(pa) => {
1416
debug!("Found PULSE_SERVER on host: {}", &pa);
1517
env_vars.insert("PULSE_SERVER".to_string(), pa);
1618
}
1719
Err(e) => {
18-
warn!("Could not get PULSE_SERVER from host: {e:?}");
20+
// Only warn in non-daemon contexts; daemon sets PULSE_SERVER explicitly per user.
21+
if crate::util::is_daemon_mode() {
22+
debug!("Could not get PULSE_SERVER from host: {e:?}");
23+
} else {
24+
warn!("Could not get PULSE_SERVER from host: {e:?}");
25+
}
1926
}
2027
}
2128
} else {

0 commit comments

Comments
 (0)