1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
use anyhow::{anyhow, Context, Result};
use clap::{crate_version, ArgAction, Parser, Subcommand};
use std::env;
use std::net::IpAddr;
use std::path::PathBuf;
use tracing_subscriber::prelude::__tracing_subscriber_SubscriberExt;
use tracing_subscriber::{prelude::*, EnvFilter};
// Pull every module from the library crate (`src/lib.rs`) so this binary
// stays a thin caller. The previous `mod config; mod manager; …` block
// here built a parallel module tree with the same source files compiled
// twice — that's gone now.
use innisfree::config::{self, clean_name};
use innisfree::doctor;
use innisfree::manager;
use innisfree::providers;
use innisfree::server::digitalocean::client::DoClient;
use innisfree::server::digitalocean::server::{DropletDefaults, DO_IMAGE, DO_REGION, DO_SIZE};
use innisfree::server::Provider;
use innisfree::state;
use innisfree::systemd;
#[derive(Debug, Parser)]
#[clap(
name = "innisfree",
about = "Exposes local services on a public IPv4 address, via a cloud server.",
version = crate_version!(),
)]
struct Args {
/// Increase log verbosity. `-v` enables debug, `-vv` enables trace.
/// Ignored if `RUST_LOG` is set in the environment.
#[clap(short, long, global = true, action = ArgAction::Count)]
verbose: u8,
/// Create new innisfree tunnel
#[clap(subcommand)]
cmd: RootCommand,
}
#[derive(Debug, Subcommand)]
enum RootCommand {
/// Exposes local services on a public IPv4 address, via a cloud server
Up {
/// Title for the service, used for cloud node and systemd service
#[clap(default_value = "innisfree", long, short, env = "INNISFREE_NAME")]
name: String,
/// List of service ports to forward, comma-separated. Specified as:
/// `<PORT>[:<LOCAL_PORT>][/PROTOCOL]. For example, the default value `80:8000/TCP`
/// will publish `80/TCP` on the external ingress, forwarding traffic
/// to `8000/TCP` on the dest ip.
#[clap(default_value = "80:8000/TCP", env = "INNISFREE_PORTS", long, short)]
ports: String,
/// IPv4 Address of proxy destination, whither traffic is forwarded
#[clap(default_value = "127.0.0.1", env = "INNISFREE_DEST_IP", long, short)]
dest_ip: IpAddr,
/// Declare pre-existing Floating IP to attach to Droplet"
#[clap(env = "INNISFREE_FLOATING_IP", long, short)]
floating_ip: Option<IpAddr>,
/// Wipe any leftover state from a prior crashed run before
/// bringing the tunnel up. Without this, `innisfree up`
/// refuses to overwrite an existing state dir and asks the
/// user to run `innisfree clean` first.
#[clap(long)]
force: bool,
/// DigitalOcean region slug (e.g. `sfo2`, `nyc3`).
#[clap(long, env = "INNISFREE_DO_REGION", default_value = DO_REGION)]
region: String,
/// DigitalOcean Droplet size slug (e.g. `s-1vcpu-1gb`).
#[clap(long, env = "INNISFREE_DO_SIZE", default_value = DO_SIZE)]
size: String,
/// DigitalOcean image slug (e.g. `debian-13-x64`).
#[clap(long, env = "INNISFREE_DO_IMAGE", default_value = DO_IMAGE)]
image: String,
},
/// Open interactive SSH shell on cloud node
Ssh {
/// Title for the service, used for cloud node and systemd service
#[clap(default_value = "innisfree", env = "INNISFREE_NAME", long, short)]
name: String,
},
/// Display IPv4 address for cloud node
Ip {
/// Title for the service, used for cloud node and systemd service
#[clap(default_value = "innisfree", env = "INNISFREE_NAME", long, short)]
name: String,
/// Emit `{"ip": "..."}` instead of a bare address, for piping
/// into other CLI tools.
#[clap(long)]
json: bool,
},
/// Run checks to evaluate platform support
Doctor {},
/// Clean local config directory.
Clean {
/// Title for the service, used for cloud node and systemd service
#[clap(default_value = "innisfree", long, short, env = "INNISFREE_NAME")]
name: String,
},
/// Render the systemd unit (`innisfree@.service`) to stdout
SystemdService {
/// Absolute path to the innisfree binary, baked into the
/// `ExecStart=` directive. Defaults to the deb-install
/// location; tests should pass the cargo-built path so
/// `systemd-analyze` (and systemd itself) finds the binary.
#[clap(long, default_value = "/usr/bin/innisfree")]
executable_path: PathBuf,
},
/// Start process to forward traffic, assumes tunnel already up
Proxy {
/// List of service ports to forward, comma-separated.
/// Each pair of service ports should be colon-separated
/// between local and remote ports: e.g. "8000:80" means
/// that a local service on 8000/TCP will receive traffic
/// sent to 80/TCP on the remote cloud node.
#[clap(default_value = "8000:80", env = "INNISFREE_PORTS", long, short)]
ports: String,
/// IPv4 Address of proxy destination, whither traffic is forwarded.
#[clap(default_value = "127.0.0.1", env = "INNISFREE_DEST_IP", long, short)]
dest_ip: IpAddr,
},
}
#[tokio::main]
/// Runs the `innisfree` CLI. Pass arguments to configure
/// local services that should be exposed remotely.
/// Pass `--help` for information.
async fn main() -> Result<()> {
let args = Args::parse();
// Set up logging via tracing-subscriber. `RUST_LOG` always wins so
// operators can override per-module filters; otherwise `-v` / `-vv`
// pick the level. All output goes to stderr so subcommands like
// `innisfree ip` can keep stdout clean for piping.
let filter_layer = match (env::var("RUST_LOG"), args.verbose) {
(Ok(_), _) => EnvFilter::from_default_env(),
(Err(_), 0) => EnvFilter::new("info"),
(Err(_), 1) => EnvFilter::new("debug"),
(Err(_), _) => EnvFilter::new("trace"),
};
let fmt_layer = tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_ansi(true)
.with_target(true);
tracing_subscriber::registry()
.with(filter_layer)
.with(fmt_layer)
.init();
// Primary subcommand. Soup to nuts experience.
match args.cmd {
RootCommand::Up {
name,
ports,
dest_ip,
floating_ip,
force,
region,
size,
image,
} => {
// Refuse to provision a billable droplet if the local
// side can't bring up a Wireguard interface. Symmetrical
// with `DoClient::from_env()` below, which gates the
// cloud-credential side.
doctor::require_cap_net_admin()?;
// Construct the DO client up front (reads DIGITALOCEAN_API_TOKEN
// exactly once) and wrap it in the provider trait object so the
// rest of the code path stays backend-agnostic. CLI overrides
// for region/size/image flow into the provider here.
let defaults = DropletDefaults {
region,
size,
image,
};
let services = config::ServicePort::from_str_multi(&ports)?;
tracing::info!("Will provide proxies for {:?}", services);
let name = clean_name(&name);
// `--force` opts back into the pre-0.5 wipe-then-create
// behaviour. Without it, `TunnelManager::new` refuses if
// leftover state from a crashed prior run is present.
if force {
tracing::warn!(
"--force: wiping any existing state dir for '{}' before bringup",
&name
);
state::remove_state_for_service(&name)?;
}
// Snapshot the bringup intent for `TunnelManager::new` to
// persist before any cloud-side calls. Cloning `defaults`
// keeps the provider as the system-of-record for the
// sizing knobs while still letting the on-disk config
// record what was requested.
let tunnel_config = state::TunnelConfig {
name: name.clone(),
services: services.clone(),
dest_ip,
floating_ip,
provider: "digitalocean".to_string(),
region: defaults.region.clone(),
size: defaults.size.clone(),
image: defaults.image.clone(),
};
let provider: Box<dyn Provider> =
providers::digitalocean(DoClient::from_env()?, defaults);
tracing::info!("Creating server '{}'", &name);
let mut mgr: manager::TunnelManager =
manager::TunnelManager::new(provider, tunnel_config).await?;
tracing::info!("Configuring server");
match mgr.up().await {
Ok(_) => {
tracing::trace!("Up reports success");
}
Err(e) => {
// `{:#}` includes the anyhow context chain on one line;
// plain `{}` only prints the outer wrap and hides the
// underlying cause (was masking real LocalWg::start
// errors during integration tests).
tracing::error!("Failed bringing up tunnel: {:#}", e);
// Error probably unrecoverable. Best-effort cleanup
// — log a clean-up failure loudly but never let it
// mask the original tunnel error.
tracing::warn!("Attempting to exit gracefully...");
if let Err(clean_err) = mgr.clean().await {
tracing::error!(
"cleanup also failed (manual cleanup may be required): {clean_err:#}"
);
}
std::process::exit(2);
}
}
// The floating-IP attachment itself happens inside
// TunnelManager::new (via InnisfreeServer::assign_floating_ip);
// here we just report whichever address users will actually hit.
let ready_ip = match floating_ip {
Some(f) => f,
None => mgr.server_ipv4()?,
};
tracing::info!("Server ready! IPv4 address: {}", ready_ip);
if name == "innisfree" {
tracing::debug!("Try logging in with 'innisfree ssh'");
} else {
tracing::debug!("Try logging in with 'innisfree ssh -n {}'", name);
}
let local_ip = mgr.local_wg_address();
// When `--dest-ip` points off-box, we run the local proxy
// ourselves; when it's loopback, the user is responsible
// for binding their own service to `local_ip`.
//
// Either way we block on signals via `mgr.block()`. The
// proxy handle (when present) is select'd against that
// wait so an early proxy failure also triggers the cloud
// teardown — the previous "spawn-and-forget" form would
// have left us blocking on signals while traffic silently
// stopped flowing.
let mut proxy_handle = if &dest_ip.to_string() != "127.0.0.1" {
Some(tokio::spawn(manager::run_proxy(
local_ip,
dest_ip,
mgr.services().to_vec(),
)))
} else {
tracing::info!(
"Ready to listen on {}. Start local services. Make sure to bind to {}, rather than 127.0.0.1!",
ports,
local_ip,
);
tracing::debug!(
"Blocking forever. Press ctrl+c to tear down the tunnel and destroy server."
);
None
};
let outcome: Result<()> = if let Some(handle) = proxy_handle.as_mut() {
tokio::select! {
// Prefer the proxy-failure branch: if both ready at
// once, we'd rather report the actual failure than
// claim a clean shutdown.
biased;
proxy_res = handle => {
tracing::error!("Local proxy stopped before a shutdown signal arrived");
if let Err(clean_err) = mgr.clean().await {
tracing::error!(
"cleanup after proxy failure also failed (manual cleanup may be required): {clean_err:#}"
);
}
match proxy_res {
Ok(Ok(())) => Err(anyhow!("proxy task exited Ok unexpectedly")),
Ok(Err(e)) => Err(e.context("proxy task failed")),
Err(je) => Err(anyhow!("proxy task panicked: {je}")),
}
}
block_res = mgr.block() => block_res,
}
} else {
mgr.block().await
};
// Whichever arm fired, the proxy is no longer useful —
// either it died (above) or we're tearing down. Abort it
// explicitly; `JoinHandle::drop` does NOT cancel the task.
if let Some(handle) = proxy_handle {
handle.abort();
}
outcome?;
}
RootCommand::Ssh { name } => {
let name = clean_name(&name);
manager::open_shell(&name).await.context(
"Server not found. Try running 'innisfree up' first, or pass --name=<service>",
)?;
}
RootCommand::Ip { name, json } => {
let name = clean_name(&name);
let ip = manager::get_server_ip(&name).context(
"Server not found. Try running 'innisfree up' first, or pass --name=<service>.",
)?;
if json {
println!("{}", serde_json::json!({ "ip": ip.to_string() }));
} else {
println!("{}", ip);
}
}
RootCommand::Doctor {} => {
tracing::info!("Running doctor, to determine platform support...");
doctor::platform_is_supported()?;
tracing::info!("Platform support looks good! Ready to rock.");
}
RootCommand::SystemdService { executable_path } => {
// print! (not println!) — the template already ends with
// a trailing newline, and a doubled newline confuses some
// systemd-analyze versions on the final stanza.
print!("{}", systemd::render_unit(&executable_path)?);
}
RootCommand::Clean { name } => {
tracing::info!("Cleaning state directory");
let name = clean_name(&name);
state::remove_state_for_service(&name)?;
}
RootCommand::Proxy { ports, dest_ip } => {
tracing::warn!(
"Subcommand 'proxy' only intended for debugging, it assumes tunnel exists already"
);
tracing::debug!(
"Blocking forever. Press ctrl+c to tear down the tunnel and destroy server."
);
// Block forever, ctrl+c will interrupt
let ports = config::ServicePort::from_str_multi(&ports)?;
let local_ip: IpAddr = "127.0.0.1".parse()?;
tracing::warn!("Ctrl+c will not halt proxy, use ctrl+z and `kill -9 %1`");
tracing::info!("Starting proxy for services {:?}", ports);
manager::run_proxy(local_ip, dest_ip, ports)
.await
.map_err(|e| anyhow!("Proxy failed: {}", e))?;
}
}
Ok(())
}