Skip to content
Merged
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
7 changes: 7 additions & 0 deletions dockerfiles/Code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ home directory instead of `/usr/local`: `NPM_CONFIG_PREFIX` defaults to
This lets users install small project CLIs with commands such as
`npm install -g cowsay` without sudo or write access to system directories.

For code-server's outgoing link protection, the Hub automatically injects the
current public Hub host into `AUPLC_CODE_TRUSTED_DOMAINS` so the built-in
Back-to-Hub action can open `/hub/home` without an external-site confirmation.
Deployments that intentionally open additional trusted sites can set
`custom.codeServer.extraTrustedDomains` to host/domain entries such as
`docs.example.edu`; do not include URL schemes or paths.

Pixi is provided as the sudo-free, apt-like package manager for user-space
native tools and project environments. The image writes `/etc/pixi/config.toml`
so requests for `https://conda.anaconda.org/conda-forge` are redirected to the
Expand Down
26 changes: 26 additions & 0 deletions dockerfiles/Code/start-code-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ workdir="${AUPLC_CODE_WORKDIR:-$(pwd)}"
extensions_list="${AUPLC_CODE_EXTENSIONS_LIST:-/opt/auplc/extensions/extensions.txt}"
local_extensions_dir="${AUPLC_CODE_LOCAL_EXTENSIONS_DIR:-/opt/auplc/extensions/local}"
extensions_dir="${AUPLC_CODE_EXTENSIONS_DIR:-/home/jovyan/.local/share/code-server/extensions}"
trusted_domains="${AUPLC_CODE_TRUSTED_DOMAINS:-}"

mkdir -p "${NPM_CONFIG_PREFIX}/bin"
mkdir -p "${PIXI_HOME}/bin"
Expand Down Expand Up @@ -50,6 +51,28 @@ seed_builtin_extensions() {
fi
}

trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "${value}"
}

build_trusted_domain_args() {
local domains_csv="$1"
local -n output_args="$2"
local -a domains=()
local domain

IFS=',' read -ra domains <<<"${domains_csv}"
for domain in "${domains[@]}"; do
domain="$(trim "${domain}")"
if [ -n "${domain}" ]; then
output_args+=(--link-protection-trusted-domains "${domain}")
fi
done
}

case "${service_prefix}" in
/*) ;;
*) service_prefix="/${service_prefix}" ;;
Expand All @@ -66,6 +89,8 @@ nginx_conf="/tmp/auplc-code-server-nginx.conf"
redirect_block=""

seed_builtin_extensions
trusted_domain_args=()
build_trusted_domain_args "${trusted_domains}" trusted_domain_args

if [ "${service_prefix}" != "/" ]; then
redirect_block="
Expand Down Expand Up @@ -121,6 +146,7 @@ code-server \
--auth none \
--bind-addr "127.0.0.1:${code_server_port}" \
--extensions-dir "${extensions_dir}" \
"${trusted_domain_args[@]}" \
--ignore-last-opened \
"${workdir}" &
code_server_pid="$!"
Expand Down
2 changes: 1 addition & 1 deletion runtime/chart/values.schema.json

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions runtime/chart/values.schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3746,6 +3746,23 @@ properties:
connections go through a reverse proxy or non-standard domain.
Use ["*"] to allow all, or list specific domains.

codeServer:
type: object
additionalProperties: false
description: |
code-server settings for browser IDE resources.
properties:
extraTrustedDomains:
type: array
items:
type: string
description: |
Additional host/domain entries trusted by code-server's outgoing
link protection. The Hub automatically adds the current public Hub
host for Back-to-Hub links; use this list only for extra trusted
sites such as internal documentation or Git services. Provide
host/domain entries, not full URLs.

apiService:
type: object
additionalProperties: false
Expand Down
5 changes: 5 additions & 0 deletions runtime/chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ custom:
notebook:
allowedOrigins: []

# code-server automatically trusts the current public Hub host for Back-to-Hub
# links. Use this only to add host/domain entries for additional trusted sites.
codeServer:
extraTrustedDomains: []

# User quota management system
quota:
# Enable/disable quota system (auto-disabled for auto-login/dummy modes if not set)
Expand Down
18 changes: 18 additions & 0 deletions runtime/hub/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ class NotebookNetworkSettings(BaseModel):
model_config = {"extra": "allow"}


class CodeServerSettings(BaseModel):
"""Settings applied to code-server resources."""

extraTrustedDomains: list[str] = Field(default_factory=list)

model_config = {"extra": "allow"}


class ParsedConfig(BaseModel):
"""Parsed configuration from values.yaml custom section."""

Expand All @@ -206,6 +214,7 @@ class ParsedConfig(BaseModel):
gitClone: GitCloneSettings = Field(default_factory=GitCloneSettings)
hub: HubNetworkSettings = Field(default_factory=HubNetworkSettings)
notebook: NotebookNetworkSettings = Field(default_factory=NotebookNetworkSettings)
codeServer: CodeServerSettings = Field(default_factory=CodeServerSettings)
notifications: dict[str, Any] = Field(default_factory=dict)

model_config = {"extra": "allow"}
Expand All @@ -220,6 +229,7 @@ def from_dicts(
git_clone: dict | None = None,
hub: dict | None = None,
notebook: dict | None = None,
code_server: dict | None = None,
notifications: dict | None = None,
) -> ParsedConfig:
"""Create configuration from individual dicts."""
Expand All @@ -239,6 +249,8 @@ def from_dicts(
raw_config["hub"] = hub
if notebook:
raw_config["notebook"] = notebook
if code_server:
raw_config["codeServer"] = code_server
if notifications is not None:
raw_config["notifications"] = notifications

Expand Down Expand Up @@ -325,6 +337,7 @@ def init(cls, config_path: str | Path) -> HubConfig:
git_clone=raw_config.get("gitClone"),
hub=raw_config.get("hub"),
notebook=raw_config.get("notebook"),
code_server=raw_config.get("codeServer"),
notifications=raw_config.get("notifications"),
)

Expand Down Expand Up @@ -413,6 +426,11 @@ def notebook_network(self) -> NotebookNetworkSettings:
"""Get notebook server network settings."""
return self._config.notebook

@property
def code_server(self) -> CodeServerSettings:
"""Get code-server settings."""
return self._config.codeServer

@property
def notifications(self) -> dict[str, Any]:
"""Get raw notification configuration for backend normalization."""
Expand Down
61 changes: 60 additions & 1 deletion runtime/hub/core/spawner/kubernetes.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ class RemoteLabKubeSpawner(KubeSpawner):
# Allowed origins for notebook server WebSocket connections
notebook_allowed_origins: list[str] = []

# Extra domains trusted by code-server's outgoing link protection.
code_server_extra_trusted_domains: list[str] = []

@classmethod
def configure_from_config(cls, config: HubConfig) -> None:
"""
Expand Down Expand Up @@ -163,6 +166,9 @@ def configure_from_config(cls, config: HubConfig) -> None:
# Extract singleuser allowed origins
cls.notebook_allowed_origins = list(config.notebook_network.allowedOrigins)

# Extract code-server link protection settings
cls.code_server_extra_trusted_domains = list(config.code_server.extraTrustedDomains)

async def get_user_resources(self) -> list[str]:
"""Get available resources for the user based on their JupyterHub group memberships.

Expand Down Expand Up @@ -363,6 +369,53 @@ def _get_public_hub_home_url(self) -> str:

return hub_path

@staticmethod
def _trusted_domain_from_url(url: str) -> str | None:
"""Extract a code-server trusted-domain host from an absolute URL."""

try:
parsed = urlparse(str(url).strip())
except Exception:
return None

if parsed.scheme not in {"http", "https"}:
return None
return (parsed.hostname or "").strip().lower() or None

@staticmethod
def _normalize_code_server_trusted_domain(domain: str) -> str | None:
"""Normalize a configured code-server trusted domain entry."""

value = str(domain).strip().lower()
if not value:
return None

# Administrators should provide host/domain patterns, not full URLs.
if "://" in value or "/" in value or "\x00" in value or any(char.isspace() for char in value):
return None
if value in {"*", "*."}:
return None

return value

def _get_code_server_trusted_domains(self, hub_url: str) -> str:
"""Return comma-separated domains trusted by code-server link protection."""

domains: list[str] = []
auto_domain = self._trusted_domain_from_url(hub_url)
if auto_domain:
domains.append(auto_domain)

domains.extend(self.code_server_extra_trusted_domains)

normalized_domains: list[str] = []
for domain in domains:
normalized = self._normalize_code_server_trusted_domain(domain)
if normalized and normalized not in normalized_domains:
normalized_domains.append(normalized)

return ",".join(normalized_domains)

def _validate_and_sanitize_repo_url(self, url: str) -> tuple[bool, str, str]:
"""
Validate and normalize a repository URL.
Expand Down Expand Up @@ -921,7 +974,13 @@ async def start(self):
launches_code_server = self._launches_code_server(resource_type)

if launches_code_server:
self.environment["AUPLC_HUB_URL"] = self._get_public_hub_home_url()
hub_url = self._get_public_hub_home_url()
self.environment["AUPLC_HUB_URL"] = hub_url
trusted_domains = self._get_code_server_trusted_domains(hub_url)
if trusted_domains:
self.environment["AUPLC_CODE_TRUSTED_DOMAINS"] = trusted_domains
else:
self.environment.pop("AUPLC_CODE_TRUSTED_DOMAINS", None)

# Inject allowed origins into notebook server startup args
if self.notebook_allowed_origins and not launches_code_server:
Expand Down
15 changes: 15 additions & 0 deletions runtime/hub/tests/test_config_resource_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def load_module(name: str, path: Path):


config = load_module("core.config", CORE / "config.py")
ParsedConfig = config.ParsedConfig
ResourceMetadata = config.ResourceMetadata


Expand Down Expand Up @@ -81,3 +82,17 @@ def test_resource_metadata_default_path_normalizes_valid_paths(default_path: str
metadata = ResourceMetadata(defaultPath=default_path)

assert metadata.defaultPath == expected_path


def test_code_server_extra_trusted_domains_default_to_empty_list():
parsed_config = ParsedConfig()

assert parsed_config.codeServer.extraTrustedDomains == []


def test_code_server_extra_trusted_domains_parse_from_config():
parsed_config = ParsedConfig.model_validate(
{"codeServer": {"extraTrustedDomains": ["docs.example.edu", "git.example.edu"]}}
)

assert parsed_config.codeServer.extraTrustedDomains == ["docs.example.edu", "git.example.edu"]
46 changes: 46 additions & 0 deletions runtime/hub/tests/test_git_clone_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def load_module(name: str, path: Path):

config = load_module("core.config", CORE / "config.py")
kubernetes = load_module("core.spawner.kubernetes", CORE / "spawner" / "kubernetes.py")
CodeServerSettings = config.CodeServerSettings
GitCloneSettings = config.GitCloneSettings
ResourceMetadata = config.ResourceMetadata
RemoteLabKubeSpawner = kubernetes.RemoteLabKubeSpawner
Expand All @@ -90,6 +91,7 @@ def get_resource_metadata(self, resource_type: str):
def make_spawner(
git_clone_settings: GitCloneSettings | None = None,
resource_metadata: dict[str, ResourceMetadata] | None = None,
code_server_settings: CodeServerSettings | None = None,
):
spawner = object.__new__(RemoteLabKubeSpawner)
spawner._hub_config = StubHubConfig(git_clone_settings, resource_metadata)
Expand All @@ -100,6 +102,7 @@ def make_spawner(
spawner.environment = {}
spawner.notebook_dir = "/home/jovyan"
spawner.default_url = ""
spawner.code_server_extra_trusted_domains = list((code_server_settings or CodeServerSettings()).extraTrustedDomains)
return spawner


Expand Down Expand Up @@ -260,3 +263,46 @@ def test_code_server_target_path_mapping_sets_workdir_without_replacing_default_

assert spawner.environment["AUPLC_CODE_WORKDIR"] == target_path
assert spawner.default_url == "/existing-default"


@pytest.mark.parametrize(
("hub_url", "expected_domain"),
[
("https://tpe.aupcloud.io/hub/home", "tpe.aupcloud.io"),
("http://aup-k8s-test.amd.com/hub/home", "aup-k8s-test.amd.com"),
("http://localhost:8000/hub/home", "localhost"),
("http://127.0.0.1:8000/hub/home", "127.0.0.1"),
("/hub/home", None),
],
)
def test_code_server_trusted_domain_from_url_uses_absolute_hub_hosts(hub_url, expected_domain):
assert RemoteLabKubeSpawner._trusted_domain_from_url(hub_url) == expected_domain


def test_code_server_trusted_domains_include_public_hub_host_and_extra_domains_once():
spawner = make_spawner(
code_server_settings=CodeServerSettings(
extraTrustedDomains=["docs.example.edu", " tpe.aupcloud.io ", "docs.example.edu"]
)
)

result = spawner._get_code_server_trusted_domains("https://tpe.aupcloud.io/hub/home")

assert result == "tpe.aupcloud.io,docs.example.edu"


def test_code_server_trusted_domains_skip_urls_and_invalid_extra_entries():
spawner = make_spawner(
code_server_settings=CodeServerSettings(
extraTrustedDomains=[
"https://docs.example.edu",
"docs.example.edu/path",
"*",
"valid.example.edu",
]
)
)

result = spawner._get_code_server_trusted_domains("/hub/home")

assert result == "valid.example.edu"
9 changes: 9 additions & 0 deletions runtime/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,15 @@ custom:
notebook:
allowedOrigins: ["*"]

# ============================================================================
# Code Server
# ============================================================================
# code-server automatically trusts the current public Hub host for outgoing
# Back-to-Hub links. Add extra domains only when a deployment intentionally
# opens trusted links to additional sites. Use host/domain entries, not URLs.
codeServer:
extraTrustedDomains: []

# ============================================================================
# Public Scheme (TLS termination outside the chart)
# ============================================================================
Expand Down
Loading