use std::fs;
use std::net::IpAddr;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::output::{run_command, CliError, CliOptions, CommandContext};
#[derive(Debug, Clone)]
pub struct TorArgs {
pub subcommand: TorSubcommand,
}
#[derive(Debug, Clone)]
pub enum TorSubcommand {
Setup {
output: Option<PathBuf>,
platform: Option<String>,
socks_port: u16,
control_port: u16,
p2p_port: u16,
kubo_api: String,
},
}
#[derive(Debug, Clone, Copy)]
enum ServicePlatform {
Systemd,
Launchd,
}
impl ServicePlatform {
fn as_str(self) -> &'static str {
match self {
ServicePlatform::Systemd => "systemd",
ServicePlatform::Launchd => "launchd",
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TorSetupOutput {
pub platform: String,
pub output_dir: String,
pub files: Vec<String>,
pub recommended_config: Vec<String>,
}
pub fn run(cwd: &Path, args: TorArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("tor", opts, |ctx| match args.subcommand {
TorSubcommand::Setup {
output,
platform,
socks_port,
control_port,
p2p_port,
kubo_api,
} => run_setup(
cwd,
output,
platform,
socks_port,
control_port,
p2p_port,
kubo_api,
ctx,
),
})
}
fn run_setup(
cwd: &Path,
output: Option<PathBuf>,
platform: Option<String>,
socks_port: u16,
control_port: u16,
p2p_port: u16,
kubo_api: String,
ctx: &mut CommandContext,
) -> Result<TorSetupOutput, CliError> {
let platform = parse_platform(platform.as_deref())?;
let output_dir = resolve_output_dir(cwd, output);
let (tor_data_dir, hidden_service_dir) = prepare_output_dirs(&output_dir)?;
let (kubo_api_multiaddr, kubo_gateway_multiaddr) = build_kubo_multiaddrs(&kubo_api)?;
let torrc_path = output_dir.join("torrc");
let service_file_name = service_file_name(platform);
let service_path = output_dir.join(service_file_name);
let kubo_proxy_file_name = kubo_proxy_file_name(platform);
let kubo_proxy_path = output_dir.join(kubo_proxy_file_name);
let kubo_script_path = output_dir.join("configure-kubo.sh");
let install_script_path = output_dir.join("install.sh");
let readme_path = output_dir.join("README.md");
let torrc = render_torrc(
&tor_data_dir,
&hidden_service_dir,
socks_port,
control_port,
p2p_port,
);
let service = render_tor_service(platform, &torrc_path);
let kubo_proxy = render_kubo_proxy(platform, socks_port);
let kubo_script = render_kubo_script(&kubo_api, &kubo_api_multiaddr, &kubo_gateway_multiaddr);
let install_script = render_install_script(platform, service_file_name, kubo_proxy_file_name);
let readme = render_readme(
platform,
service_file_name,
kubo_proxy_file_name,
socks_port,
control_port,
p2p_port,
);
let artifacts = vec![
GeneratedArtifact::new(torrc_path, torrc),
GeneratedArtifact::new(service_path, service),
GeneratedArtifact::new(kubo_proxy_path, kubo_proxy),
GeneratedArtifact::new_executable(kubo_script_path, kubo_script),
GeneratedArtifact::new_executable(install_script_path, install_script),
GeneratedArtifact::new(readme_path, readme),
];
write_artifacts(&artifacts)?;
let files = artifacts
.iter()
.map(|a| a.path.display().to_string())
.collect::<Vec<_>>();
let recommended_config =
build_recommended_config(socks_port, control_port, p2p_port, &kubo_api);
emit_setup_summary(ctx, &output_dir, platform);
Ok(TorSetupOutput {
platform: platform.as_str().to_string(),
output_dir: output_dir.display().to_string(),
files,
recommended_config,
})
}
#[derive(Debug, Clone)]
struct GeneratedArtifact {
path: PathBuf,
content: String,
executable: bool,
}
impl GeneratedArtifact {
fn new(path: PathBuf, content: String) -> Self {
Self {
path,
content,
executable: false,
}
}
fn new_executable(path: PathBuf, content: String) -> Self {
Self {
path,
content,
executable: true,
}
}
}
fn resolve_output_dir(cwd: &Path, output: Option<PathBuf>) -> PathBuf {
match output {
Some(path) if path.is_absolute() => path,
Some(path) => cwd.join(path),
None => default_output_dir(),
}
}
fn prepare_output_dirs(output_dir: &Path) -> Result<(PathBuf, PathBuf), CliError> {
fs::create_dir_all(output_dir)
.map_err(|e| CliError::internal(format!("failed to create output directory: {e}")))?;
let tor_data_dir = output_dir.join("tor-data");
let hidden_service_dir = output_dir.join("hidden-service");
fs::create_dir_all(&tor_data_dir)
.map_err(|e| CliError::internal(format!("failed to create tor data directory: {e}")))?;
fs::create_dir_all(&hidden_service_dir).map_err(|e| {
CliError::internal(format!("failed to create hidden service directory: {e}"))
})?;
set_mode_if_unix(&tor_data_dir, 0o700)?;
set_mode_if_unix(&hidden_service_dir, 0o700)?;
Ok((tor_data_dir, hidden_service_dir))
}
fn write_artifacts(artifacts: &[GeneratedArtifact]) -> Result<(), CliError> {
for artifact in artifacts {
write_text_file(&artifact.path, &artifact.content)?;
if artifact.executable {
set_executable_if_unix(&artifact.path)?;
}
}
Ok(())
}
fn emit_setup_summary(ctx: &mut CommandContext, output_dir: &Path, platform: ServicePlatform) {
if !ctx.use_json() {
ctx.info(format!(
"Generated Tor/Kubo config templates in {}",
output_dir.display()
));
ctx.info(format!("Platform: {}", platform.as_str()));
ctx.info("Next steps:");
ctx.info(format!(" 1) cd {} && ./install.sh", output_dir.display()));
ctx.info(format!(
" 2) cd {} && ./configure-kubo.sh",
output_dir.display()
));
ctx.info(" 3) set repo config keys with 'void config <key> <value>' (see README.md)");
}
}
fn build_recommended_config(
socks_port: u16,
control_port: u16,
p2p_port: u16,
kubo_api: &str,
) -> Vec<String> {
let socks_proxy = format!("socks5h://127.0.0.1:{socks_port}");
vec![
"tor.mode=external".to_string(),
format!("tor.socksProxy={socks_proxy}"),
format!("tor.control=127.0.0.1:{control_port}"),
"tor.cookieAuth=true".to_string(),
"tor.hiddenService.enabled=true".to_string(),
format!("tor.hiddenService.virtualPort={p2p_port}"),
format!("tor.hiddenService.target=127.0.0.1:{p2p_port}"),
"tor.kubo.enable=true".to_string(),
"tor.kubo.setEnvProxy=true".to_string(),
"ipfs.backend=kubo".to_string(),
format!("ipfs.kuboApi={kubo_api}"),
]
}
#[derive(Debug, Clone)]
struct HttpEndpoint {
host: String,
port: u16,
}
fn build_kubo_multiaddrs(kubo_api: &str) -> Result<(String, String), CliError> {
let endpoint = parse_http_endpoint(kubo_api)?;
let api_multiaddr = endpoint_to_multiaddr(&endpoint.host, endpoint.port);
let gateway_multiaddr = endpoint_to_multiaddr(&endpoint.host, 8080);
Ok((api_multiaddr, gateway_multiaddr))
}
fn parse_http_endpoint(url: &str) -> Result<HttpEndpoint, CliError> {
let trimmed = url.trim();
let (scheme, rest) = if let Some(v) = trimmed.strip_prefix("http://") {
("http", v)
} else if let Some(v) = trimmed.strip_prefix("https://") {
("https", v)
} else {
return Err(CliError::invalid_args(format!(
"invalid kubo API URL '{url}': expected http:// or https://"
)));
};
let authority = rest
.split('/')
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
CliError::invalid_args(format!("invalid kubo API URL '{url}': missing host"))
})?;
let authority = authority.rsplit('@').next().unwrap_or(authority);
let default_port = if scheme == "https" { 443 } else { 80 };
let (host, port) = if authority.starts_with('[') {
let end = authority.find(']').ok_or_else(|| {
CliError::invalid_args(format!("invalid kubo API URL '{url}': malformed IPv6 host"))
})?;
let host = authority[1..end].to_string();
let trailing = &authority[end + 1..];
let port = if let Some(port_str) = trailing.strip_prefix(':') {
port_str.parse().map_err(|_| {
CliError::invalid_args(format!("invalid kubo API URL '{url}': invalid port"))
})?
} else {
default_port
};
(host, port)
} else if let Some((host_part, port_part)) = authority.rsplit_once(':') {
if host_part.contains(':') {
(authority.to_string(), default_port)
} else {
let port = port_part.parse().map_err(|_| {
CliError::invalid_args(format!("invalid kubo API URL '{url}': invalid port"))
})?;
(host_part.to_string(), port)
}
} else {
(authority.to_string(), default_port)
};
if host.is_empty() {
return Err(CliError::invalid_args(format!(
"invalid kubo API URL '{url}': missing host"
)));
}
Ok(HttpEndpoint { host, port })
}
fn endpoint_to_multiaddr(host: &str, port: u16) -> String {
match host.parse::<IpAddr>() {
Ok(IpAddr::V4(ip)) => format!("/ip4/{ip}/tcp/{port}"),
Ok(IpAddr::V6(ip)) => format!("/ip6/{ip}/tcp/{port}"),
Err(_) => format!("/dns/{host}/tcp/{port}"),
}
}
fn parse_platform(platform: Option<&str>) -> Result<ServicePlatform, CliError> {
match platform {
Some("systemd") => Ok(ServicePlatform::Systemd),
Some("launchd") => Ok(ServicePlatform::Launchd),
Some(other) => Err(CliError::invalid_args(format!(
"unknown platform '{other}', expected 'systemd' or 'launchd'"
))),
None => Ok(detect_platform()),
}
}
fn detect_platform() -> ServicePlatform {
if cfg!(target_os = "macos") {
ServicePlatform::Launchd
} else {
ServicePlatform::Systemd
}
}
fn default_output_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".void")
.join("services")
.join("tor")
}
fn write_text_file(path: &Path, content: &str) -> Result<(), CliError> {
fs::write(path, content)
.map_err(|e| CliError::internal(format!("failed to write {}: {}", path.display(), e)))
}
fn set_executable_if_unix(path: &Path) -> Result<(), CliError> {
#[cfg(unix)]
{
let mut perms = fs::metadata(path)
.map_err(|e| {
CliError::internal(format!(
"failed to read metadata for {}: {}",
path.display(),
e
))
})?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).map_err(|e| {
CliError::internal(format!(
"failed to set executable bit on {}: {}",
path.display(),
e
))
})?;
}
Ok(())
}
fn set_mode_if_unix(path: &Path, mode: u32) -> Result<(), CliError> {
#[cfg(unix)]
{
let mut perms = fs::metadata(path)
.map_err(|e| {
CliError::internal(format!(
"failed to read metadata for {}: {}",
path.display(),
e
))
})?
.permissions();
perms.set_mode(mode);
fs::set_permissions(path, perms).map_err(|e| {
CliError::internal(format!(
"failed to set mode {:o} on {}: {}",
mode,
path.display(),
e
))
})?;
}
Ok(())
}
fn service_file_name(platform: ServicePlatform) -> &'static str {
match platform {
ServicePlatform::Systemd => "void-tor.service",
ServicePlatform::Launchd => "com.void.tor.plist",
}
}
fn kubo_proxy_file_name(platform: ServicePlatform) -> &'static str {
match platform {
ServicePlatform::Systemd => "kubo-tor-systemd.conf",
ServicePlatform::Launchd => "kubo-tor-launchd.env",
}
}
fn render_torrc(
data_dir: &Path,
hidden_service_dir: &Path,
socks_port: u16,
control_port: u16,
p2p_port: u16,
) -> String {
format!(
"\
SocksPort 127.0.0.1:{socks_port}
ControlPort 127.0.0.1:{control_port}
CookieAuthentication 1
DataDirectory {}
HiddenServiceDir {}
HiddenServicePort {p2p_port} 127.0.0.1:{p2p_port}
",
data_dir.display(),
hidden_service_dir.display()
)
}
fn render_tor_service(platform: ServicePlatform, torrc_path: &Path) -> String {
match platform {
ServicePlatform::Systemd => {
format!(
"\
[Unit]
Description=void Tor daemon
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=tor -f {}
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
",
torrc_path.display()
)
}
ServicePlatform::Launchd => {
format!(
"\
<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
<plist version=\"1.0\">
<dict>
<key>Label</key>
<string>com.void.tor</string>
<key>ProgramArguments</key>
<array>
<string>tor</string>
<string>-f</string>
<string>{}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
",
torrc_path.display()
)
}
}
}
fn render_kubo_proxy(platform: ServicePlatform, socks_port: u16) -> String {
let proxy = format!("socks5h://127.0.0.1:{socks_port}");
match platform {
ServicePlatform::Systemd => format!(
"\
[Service]
Environment=\"ALL_PROXY={proxy}\"
Environment=\"HTTP_PROXY={proxy}\"
Environment=\"HTTPS_PROXY={proxy}\"
Environment=\"NO_PROXY=127.0.0.1,localhost\"
"
),
ServicePlatform::Launchd => format!(
"\
ALL_PROXY={proxy}
HTTP_PROXY={proxy}
HTTPS_PROXY={proxy}
NO_PROXY=127.0.0.1,localhost
"
),
}
}
fn render_kubo_script(
kubo_api: &str,
kubo_api_multiaddr: &str,
kubo_gateway_multiaddr: &str,
) -> String {
format!(
"\
#!/usr/bin/env bash
set -euo pipefail
if ! command -v ipfs >/dev/null 2>&1; then
echo \"ipfs command not found\" >&2
exit 1
fi
ipfs config Addresses.API {kubo_api_multiaddr}
ipfs config Addresses.Gateway {kubo_gateway_multiaddr}
ipfs config Swarm.DisableNatPortMap true
echo \"Kubo API expected at: {kubo_api}\"
echo \"Kubo basic privacy config applied.\"
"
)
}
fn render_install_script(
platform: ServicePlatform,
service_file_name: &str,
kubo_proxy_file_name: &str,
) -> String {
match platform {
ServicePlatform::Systemd => format!(
"\
#!/usr/bin/env bash
set -euo pipefail
ROOT=\"$(cd \"$(dirname \"$0\")\" && pwd)\"
mkdir -p \"$HOME/.config/systemd/user\"
cp \"$ROOT/{service_file_name}\" \"$HOME/.config/systemd/user/void-tor.service\"
mkdir -p \"$HOME/.config/systemd/user/kubo.service.d\"
cp \"$ROOT/{kubo_proxy_file_name}\" \"$HOME/.config/systemd/user/kubo.service.d/tor-proxy.conf\"
systemctl --user daemon-reload
systemctl --user enable --now void-tor.service
systemctl --user restart kubo.service || true
echo \"Installed void-tor service and kubo proxy override (systemd user units).\"
"
),
ServicePlatform::Launchd => format!(
"\
#!/usr/bin/env bash
set -euo pipefail
ROOT=\"$(cd \"$(dirname \"$0\")\" && pwd)\"
mkdir -p \"$HOME/Library/LaunchAgents\"
cp \"$ROOT/{service_file_name}\" \"$HOME/Library/LaunchAgents/com.void.tor.plist\"
launchctl unload \"$HOME/Library/LaunchAgents/com.void.tor.plist\" 2>/dev/null || true
launchctl load \"$HOME/Library/LaunchAgents/com.void.tor.plist\"
echo \"Installed com.void.tor launchd agent.\"
echo \"Apply env vars from {kubo_proxy_file_name} to your kubo launchd service manually.\"
"
),
}
}
fn render_readme(
platform: ServicePlatform,
service_file_name: &str,
kubo_proxy_file_name: &str,
socks_port: u16,
control_port: u16,
p2p_port: u16,
) -> String {
let socks_proxy = format!("socks5h://127.0.0.1:{socks_port}");
let platform_name = platform.as_str();
format!(
"\
# void Tor/Kubo setup
This folder was generated by `void tor setup`.
Files:
- `{service_file_name}`: Tor daemon service definition for {platform_name}
- `torrc`: Tor daemon config with hidden service for void P2P
- `{kubo_proxy_file_name}`: Kubo proxy environment template
- `configure-kubo.sh`: Applies privacy-oriented Kubo config values
- `install.sh`: Installs service files for this platform
Recommended repo config keys:
- `void config tor.mode external`
- `void config tor.socksProxy {socks_proxy}`
- `void config tor.control 127.0.0.1:{control_port}`
- `void config tor.cookieAuth true`
- `void config tor.hiddenService.enabled true`
- `void config tor.hiddenService.virtualPort {p2p_port}`
- `void config tor.hiddenService.target 127.0.0.1:{p2p_port}`
- `void config tor.kubo.enable true`
- `void config tor.kubo.setEnvProxy true`
"
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn parse_platform_rejects_unknown() {
let result = parse_platform(Some("bad-platform"));
assert!(result.is_err());
}
#[test]
fn torrc_contains_hidden_service_port() {
let torrc = render_torrc(
Path::new("/tmp/tor-data"),
Path::new("/tmp/hidden-service"),
9050,
9051,
4001,
);
assert!(torrc.contains("SocksPort 127.0.0.1:9050"));
assert!(torrc.contains("ControlPort 127.0.0.1:9051"));
assert!(torrc.contains("HiddenServicePort 4001 127.0.0.1:4001"));
}
#[test]
fn parse_http_endpoint_extracts_host_and_port() {
let parsed = parse_http_endpoint("http://127.0.0.1:5001").unwrap();
assert_eq!(parsed.host, "127.0.0.1");
assert_eq!(parsed.port, 5001);
}
#[test]
fn parse_http_endpoint_rejects_invalid_scheme() {
let parsed = parse_http_endpoint("tcp://127.0.0.1:5001");
assert!(parsed.is_err());
}
#[test]
fn build_recommended_config_includes_kubo_api() {
let values = build_recommended_config(9050, 9051, 4001, "http://10.0.0.1:7777");
assert!(values
.iter()
.any(|v| v == "ipfs.kuboApi=http://10.0.0.1:7777"));
}
#[test]
fn render_kubo_script_uses_custom_api_multiaddr() {
let script = render_kubo_script(
"http://10.0.0.1:7777",
"/ip4/10.0.0.1/tcp/7777",
"/ip4/10.0.0.1/tcp/8080",
);
assert!(script.contains("ipfs config Addresses.API /ip4/10.0.0.1/tcp/7777"));
}
#[cfg(unix)]
#[test]
fn prepare_output_dirs_sets_private_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let out = dir.path().join("tor-out");
let (tor_data, hidden_service) = prepare_output_dirs(&out).unwrap();
let tor_mode = fs::metadata(&tor_data).unwrap().permissions().mode() & 0o777;
let hidden_mode = fs::metadata(&hidden_service).unwrap().permissions().mode() & 0o777;
assert_eq!(tor_mode, 0o700);
assert_eq!(hidden_mode, 0o700);
}
}