Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@
# Host port mapped to the k3s NodePort (30051) where the OpenShell gateway
# listens. The CLI connects here. Must be unique per cluster.
#GATEWAY_PORT=8080

# Optional gateway host override for `openshell gateway start`.
# `mise run cluster` still pins `--backend k3s`, but host-run backends use
# this value to configure SSH advertisements and sandbox callback routing.
#OPENSHELL_GATEWAY_HOST=host.docker.internal
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/openshell-bootstrap/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ pub use crate::docker::{
DockerPreflight, ExistingGatewayInfo, check_docker_available, create_ssh_docker_client,
};
pub use crate::metadata::{
GatewayMetadata, clear_active_gateway, clear_last_sandbox_if_matches,
GatewayBackend, GatewayMetadata, clear_active_gateway, clear_last_sandbox_if_matches,
extract_host_from_ssh_destination, get_gateway_metadata, list_gateways, load_active_gateway,
load_gateway_metadata, load_last_sandbox, remove_gateway_metadata, resolve_ssh_hostname,
save_active_gateway, save_last_sandbox, store_gateway_metadata,
Expand Down
122 changes: 116 additions & 6 deletions crates/openshell-bootstrap/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,53 @@ use crate::paths::{active_gateway_path, gateways_dir, last_sandbox_path};
use miette::{IntoDiagnostic, Result, WrapErr};
use openshell_core::paths::ensure_parent_dir_restricted;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;

/// Managed gateway backends supported by the CLI.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GatewayBackend {
K3s,
Kubernetes,
Vm,
Podman,
}

impl GatewayBackend {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::K3s => "k3s",
Self::Kubernetes => "kubernetes",
Self::Vm => "vm",
Self::Podman => "podman",
}
}
}

impl fmt::Display for GatewayBackend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}

impl FromStr for GatewayBackend {
type Err = String;

fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"k3s" => Ok(Self::K3s),
"kubernetes" => Ok(Self::Kubernetes),
"vm" => Ok(Self::Vm),
"podman" => Ok(Self::Podman),
other => Err(format!(
"unsupported gateway backend '{other}'. expected one of: k3s, kubernetes, vm, podman"
)),
}
}
}

/// Gateway metadata stored alongside deployment info.
#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -31,6 +77,19 @@ pub struct GatewayMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_mode: Option<String>,

/// Managed gateway backend, when this registration was created by
/// `openshell gateway start`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<GatewayBackend>,

/// Host override used to configure the running gateway itself.
///
/// This is distinct from `gateway_endpoint`: local host-run gateways keep
/// their CLI endpoint on loopback but still need a stable configured host
/// for SSH advertisements and backend-specific callback derivation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub configured_gateway_host: Option<String>,

/// Edge proxy team/org domain (e.g., `brevlab.cloudflareaccess.com`).
#[serde(
default,
Expand All @@ -55,6 +114,14 @@ impl GatewayMetadata {
/// address (`127.0.0.1`, `localhost`, `::1`) — those are never meaningful
/// as a `--gateway-host` override.
pub fn gateway_host(&self) -> Option<&str> {
if let Some(host) = self
.configured_gateway_host
.as_deref()
.filter(|host| !is_loopback_host(host))
{
return Some(host);
}

// Endpoint format: "https://host:port" or "http://host:port"
let after_scheme = self
.gateway_endpoint
Expand All @@ -64,16 +131,19 @@ impl GatewayMetadata {
let host = after_scheme
.rsplit_once(':')
.map_or(after_scheme, |(h, _)| h);
if host.is_empty()
|| host == "127.0.0.1"
|| host == "localhost"
|| host == "::1"
|| host == "[::1]"
{
if host.is_empty() || is_loopback_host(host) {
return None;
}
Some(host)
}

/// Return the managed backend when known, falling back to `k3s` for legacy
/// Docker-managed gateways that predate the `backend` field.
#[must_use]
pub fn backend(&self) -> Option<GatewayBackend> {
self.backend
.or_else(|| (self.gateway_port > 0).then_some(GatewayBackend::K3s))
}
}

pub fn create_gateway_metadata(
Expand Down Expand Up @@ -134,11 +204,20 @@ pub fn create_gateway_metadata_with_host(
remote_host,
resolved_host,
auth_mode: disable_tls.then(|| "plaintext".to_string()),
backend: Some(GatewayBackend::K3s),
configured_gateway_host: gateway_host.map(ToOwned::to_owned),
edge_team_domain: None,
edge_auth_url: None,
}
}

fn is_loopback_host(host: &str) -> bool {
host == "127.0.0.1"
|| host.eq_ignore_ascii_case("localhost")
|| host == "::1"
|| host == "[::1]"
}

pub fn local_gateway_host() -> Option<String> {
std::env::var("DOCKER_HOST")
.ok()
Expand Down Expand Up @@ -462,6 +541,8 @@ mod tests {
remote_host: Some("user@openshell-dev".to_string()),
resolved_host: Some("10.0.0.5".to_string()),
auth_mode: None,
backend: Some(GatewayBackend::K3s),
configured_gateway_host: None,
edge_team_domain: None,
edge_auth_url: None,
};
Expand Down Expand Up @@ -502,6 +583,11 @@ mod tests {
assert!(!meta.is_remote);
assert!(meta.remote_host.is_none());
assert!(meta.resolved_host.is_none());
assert_eq!(meta.backend(), Some(GatewayBackend::K3s));
assert_eq!(
meta.configured_gateway_host.as_deref(),
Some("host.docker.internal")
);
}

#[test]
Expand Down Expand Up @@ -557,6 +643,8 @@ mod tests {
remote_host: None,
resolved_host: None,
auth_mode: None,
backend: Some(GatewayBackend::K3s),
configured_gateway_host: None,
edge_team_domain: None,
edge_auth_url: None,
};
Expand All @@ -573,12 +661,34 @@ mod tests {
remote_host: Some("user@10.0.0.5".into()),
resolved_host: Some("10.0.0.5".into()),
auth_mode: None,
backend: Some(GatewayBackend::K3s),
configured_gateway_host: None,
edge_team_domain: None,
edge_auth_url: None,
};
assert_eq!(meta.gateway_host(), Some("10.0.0.5"));
}

#[test]
fn gateway_host_prefers_configured_host_for_loopback_endpoint() {
let meta = GatewayMetadata {
name: "t".into(),
gateway_endpoint: "https://127.0.0.1:8080".into(),
is_remote: false,
gateway_port: 8080,
remote_host: None,
resolved_host: None,
auth_mode: None,
backend: Some(GatewayBackend::Kubernetes),
configured_gateway_host: Some("gateway.internal".into()),
edge_team_domain: None,
edge_auth_url: None,
};

assert_eq!(meta.gateway_host(), Some("gateway.internal"));
assert_eq!(meta.backend(), Some(GatewayBackend::Kubernetes));
}

#[test]
fn gateway_host_handles_http_scheme() {
let meta =
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ openshell-policy = { path = "../openshell-policy" }
openshell-providers = { path = "../openshell-providers" }
openshell-prover = { path = "../openshell-prover" }
openshell-tui = { path = "../openshell-tui" }
base64 = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
prost-types = { workspace = true }
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-cli/src/completers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ mod tests {
remote_host: None,
resolved_host: None,
auth_mode: Some("cloudflare_jwt".to_string()),
backend: None,
configured_gateway_host: None,
edge_team_domain: None,
edge_auth_url: None,
},
Expand Down
38 changes: 35 additions & 3 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ use owo_colors::OwoColorize;
use std::io::Write;

use openshell_bootstrap::{
edge_token::load_edge_token, get_gateway_metadata, list_gateways, load_active_gateway,
load_gateway_metadata, load_last_sandbox, save_last_sandbox,
GatewayBackend, edge_token::load_edge_token, get_gateway_metadata, list_gateways,
load_active_gateway, load_gateway_metadata, load_last_sandbox, save_last_sandbox,
};
use openshell_cli::completers;
use openshell_cli::run;
Expand All @@ -26,6 +26,25 @@ struct GatewayContext {
endpoint: String,
}

#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum GatewayBackendArg {
K3s,
Kubernetes,
Vm,
Podman,
}

impl From<GatewayBackendArg> for GatewayBackend {
fn from(value: GatewayBackendArg) -> Self {
match value {
GatewayBackendArg::K3s => Self::K3s,
GatewayBackendArg::Kubernetes => Self::Kubernetes,
GatewayBackendArg::Vm => Self::Vm,
GatewayBackendArg::Podman => Self::Podman,
}
}
}

/// Resolve the gateway name to a [`GatewayContext`] with the gateway endpoint.
///
/// Resolution priority:
Expand Down Expand Up @@ -744,6 +763,15 @@ enum GatewayCommands {
#[arg(long, default_value = "openshell", env = "OPENSHELL_GATEWAY")]
name: String,

/// Gateway deployment backend.
#[arg(
long,
value_enum,
default_value = "k3s",
env = "OPENSHELL_GATEWAY_BACKEND"
)]
backend: GatewayBackendArg,

/// SSH destination for remote deployment (e.g., user@hostname).
#[arg(long)]
remote: Option<String>,
Expand All @@ -763,7 +791,7 @@ enum GatewayCommands {
/// example in CI containers, WSL, or when Docker runs on a
/// remote host. Common values: `host.docker.internal`, a LAN IP,
/// or a hostname.
#[arg(long)]
#[arg(long, env = "OPENSHELL_GATEWAY_HOST")]
gateway_host: Option<String>,

/// Destroy and recreate the gateway from scratch if one already exists.
Expand Down Expand Up @@ -1644,6 +1672,7 @@ async fn main() -> Result<()> {
}) => match command {
GatewayCommands::Start {
name,
backend,
remote,
ssh_key,
port,
Expand All @@ -1662,6 +1691,7 @@ async fn main() -> Result<()> {
};
run::gateway_admin_deploy(
&name,
backend.into(),
remote.as_deref(),
ssh_key.as_deref(),
port,
Expand Down Expand Up @@ -2699,6 +2729,8 @@ mod tests {
remote_host: None,
resolved_host: None,
auth_mode: Some("cloudflare_jwt".to_string()),
backend: None,
configured_gateway_host: None,
edge_team_domain: None,
edge_auth_url: None,
}
Expand Down
Loading
Loading