Skip to main content

mobius_gateway/command/
init.rs

1use super::*;
2
3pub(super) fn initialize(options: InitOptions) -> Result<()> {
4    let (store, config) = match options.cloudflare {
5        Some(CloudflareInit::Quick) => {
6            ConfigStore::initialize_quick_cloudflare(options.state_dir, options.listen)?
7        }
8        Some(CloudflareInit::Named { hostname, token }) => {
9            ConfigStore::initialize_named_cloudflare(
10                options.state_dir,
11                options.listen,
12                &hostname,
13                &token,
14            )?
15        }
16        None if options.tls.is_none() => {
17            ConfigStore::initialize_quick_cloudflare(options.state_dir, options.listen)?
18        }
19        None => ConfigStore::initialize(options.state_dir, options.listen, options.tls)?,
20    };
21    initialize_auth(&store, config.auth)?;
22    println!("initialized möbius gateway");
23    print_listener(&config, None);
24    println!("run `mobius-gateway connect` to pair a client");
25    Ok(())
26}
27
28pub(super) fn initialize_auth(store: &ConfigStore, policy: crate::auth::AuthConfig) -> Result<()> {
29    if let Err(error) = AuthStore::initialize(store.auth_path(), policy) {
30        return cleanup_failed_initialization(store, error);
31    }
32    Ok(())
33}
34
35pub(super) fn initialize_bootstrap(
36    state_dir: PathBuf,
37    save_local_client: fn(&Endpoint, String) -> Result<()>,
38) -> Result<()> {
39    let (store, config) = ConfigStore::initialize(state_dir, DEFAULT_LISTEN, None)?;
40    let initialized =
41        AuthStore::initialize(store.auth_path(), config.auth).and_then(|(auth, _)| {
42            let endpoint = direct_loopback_endpoint(&config)?;
43            let issued = auth.provision_local_client()?;
44            save_local_client(&endpoint, issued.token)
45        });
46    if let Err(error) = initialized {
47        return cleanup_failed_initialization(&store, error);
48    }
49    println!("initialized möbius gateway bootstrap");
50    print_listener(&config, None);
51    Ok(())
52}
53
54pub(super) fn reset_bot_defaults(state_dir: PathBuf) -> Result<()> {
55    #[cfg(unix)]
56    {
57        let (store, _) = ConfigStore::open(state_dir)?;
58        let _startup = StartupGuard::create(store.state_dir())?;
59        stop_gateway(store.state_dir(), None)?;
60        let (store, config) = ConfigStore::open(store.state_dir().to_path_buf())?;
61        let current = config.bot_defaults.as_ref().ok_or_else(|| {
62            Error::Config("configure a provider before resetting defaults".into())
63        })?;
64        let composition = crate::wire::AgentComposition {
65            provider: current.config.provider.clone(),
66            ..crate::wire::AgentComposition::default()
67        };
68        let config = config.replacing_bot_defaults(current.revision, composition)?;
69        store.save(&config)?;
70        println!("reset möbius gateway Bot defaults");
71        Ok(())
72    }
73    #[cfg(not(unix))]
74    {
75        let _ = state_dir;
76        Err(unsupported_lifecycle())
77    }
78}
79
80pub(super) fn set_desktop(state_dir: PathBuf, enabled: bool) -> Result<()> {
81    let (store, mut config) = ConfigStore::open(state_dir)?;
82    let _startup = StartupGuard::create(store.state_dir())?;
83    ensure_gateway_stopped(&store, &config)?;
84    config.desktop_enabled = enabled;
85    config.validate()?;
86    for bot in crate::bots::BotStore::open(store.state_dir())?.bots()? {
87        crate::config::validate_desktop_bot_policy(&config, &bot.config.config)?;
88    }
89    store.save(&config)?;
90    println!("gateway desktop enabled: {enabled}");
91    Ok(())
92}
93
94pub(super) fn direct_loopback_endpoint(config: &GatewayConfig) -> Result<Endpoint> {
95    if !config.listen.ip().is_loopback() || config.tls.is_some() || config.cloudflare.is_some() {
96        return Err(Error::Config(
97            "bootstrap commands require a direct plaintext loopback gateway".into(),
98        ));
99    }
100    loopback_endpoint(config)
101}
102
103fn cleanup_failed_initialization<T>(store: &ConfigStore, error: Error) -> Result<T> {
104    std::fs::remove_dir_all(store.state_dir()).map_err(|cleanup| {
105        Error::Config(format!(
106            "{error}; failed to remove incomplete gateway state at {}: {cleanup}",
107            store.state_dir().display()
108        ))
109    })?;
110    Err(error)
111}
112
113pub(super) fn provision_cloudflare_local_client(
114    auth: &AuthStore,
115    config: &GatewayConfig,
116) -> Result<Option<(Endpoint, String)>> {
117    if config.cloudflare.is_none() {
118        return Ok(None);
119    }
120    let endpoint = loopback_endpoint(config)?;
121    let issued = auth.provision_local_client()?;
122    Ok(Some((endpoint, issued.token)))
123}
124
125pub(super) fn loopback_endpoint(config: &GatewayConfig) -> Result<Endpoint> {
126    format!("tcp://{}", config.listen).parse()
127}
128
129/// Initializes one gateway with an account-free Cloudflare Quick Tunnel.
130/// # Errors
131///
132/// Returns an error if configuration is invalid or a required resource cannot be initialized.
133pub fn initialize_quick_cloudflare(state_dir: PathBuf) -> Result<()> {
134    initialize(InitOptions {
135        state_dir,
136        listen: DEFAULT_LISTEN,
137        tls: None,
138        cloudflare: Some(CloudflareInit::Quick),
139    })
140}
141
142/// Initializes one gateway against a user-owned named Cloudflare Tunnel.
143/// # Errors
144///
145/// Returns an error if configuration is invalid or a required resource cannot be initialized.
146pub fn initialize_named_cloudflare(
147    state_dir: PathBuf,
148    hostname: String,
149    token: String,
150) -> Result<()> {
151    initialize(InitOptions {
152        state_dir,
153        listen: DEFAULT_LISTEN,
154        tls: None,
155        cloudflare: Some(CloudflareInit::Named { hostname, token }),
156    })
157}
158
159/// Permanently removes previously confirmed gateway state after stopping its process.
160///
161/// # Errors
162///
163/// Returns an error unless the target is an empty real directory or contains a regular
164/// `gateway.toml` marker. Lifecycle or filesystem failures are also returned.
165pub fn reset_gateway_state(state_dir: PathBuf) -> Result<()> {
166    #[cfg(unix)]
167    {
168        let had_config = validate_reset_target(&state_dir, false)?;
169        let state_dir = fs::canonicalize(state_dir)?;
170        let _startup = StartupGuard::create(&state_dir)?;
171        if validate_reset_target(&state_dir, true)? != had_config {
172            return Err(invalid_reset_target(&state_dir));
173        }
174        stop_gateway(&state_dir, None)?;
175        fs::remove_dir_all(state_dir)?;
176        Ok(())
177    }
178    #[cfg(not(unix))]
179    {
180        let _ = state_dir;
181        Err(unsupported_lifecycle())
182    }
183}
184
185#[cfg(unix)]
186pub(super) fn validate_reset_target(path: &Path, ignore_startup_lock: bool) -> Result<bool> {
187    let metadata = fs::symlink_metadata(path)?;
188    if metadata.file_type().is_symlink() || !metadata.is_dir() {
189        return Err(invalid_reset_target(path));
190    }
191    let mut empty = true;
192    for entry in fs::read_dir(path)? {
193        let entry = entry?;
194        if ignore_startup_lock && entry.file_name() == STARTUP_FILE {
195            continue;
196        }
197        empty = false;
198    }
199    if empty {
200        return Ok(false);
201    }
202    let marker = fs::symlink_metadata(path.join(STATE_MARKER_FILE))
203        .map_err(|_| invalid_reset_target(path))?;
204    if !marker.is_file() || marker.file_type().is_symlink() {
205        return Err(invalid_reset_target(path));
206    }
207    Ok(true)
208}
209
210#[cfg(unix)]
211pub(super) fn invalid_reset_target(path: &Path) -> Error {
212    Error::Config(format!(
213        "refusing to reset {}: expected an empty directory or möbius gateway state with a regular {STATE_MARKER_FILE}",
214        path.display()
215    ))
216}