Skip to content
Draft
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project are documented here, grouped by tagged versi

## [UNRELEASED] - Under development

### Added
- Rewrite Lab Instance service links to a reverse-proxy vhost
(`{port}-{service-name}.{PROXY_DOMAIN}`) when the vhost responds, controlled
by the new `PROXY_DOMAIN` setting.

## [2.0.15] - 2026-08-27

### Fixed
Expand Down
5 changes: 5 additions & 0 deletions apps/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ class Config(object):
LAB_EXPIRATION_WARN_SEC = 48*60*60
LAB_EXPIRATION_TOLERANACE_SEC = 48*60*60

# Reverse-proxy vhost domain. When set, service links in the Lab Instance
# view are probed against "{port}-{service-name}.{PROXY_DOMAIN}" and swapped
# to that URL when the vhost responds. Empty disables the behavior.
PROXY_DOMAIN = os.getenv("PROXY_DOMAIN", "")

#Testbed infos
TESTBED_TITLE = os.getenv("TESTBED_TITLE", "HackInSDN Testbed")
TESTBED_NODES_GEOTAG = {
Expand Down
9 changes: 9 additions & 0 deletions apps/controllers/kubernetes.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ def get_lab_resources(self, resources, published_ports={}):
owners.update(rs_uid_to_dep.keys())

pod_services = {}
pod_services_proxy = {}
pod_names = {}
app_pod_map = defaultdict(list)
pods_by_uid = {}
Expand All @@ -534,6 +535,7 @@ def get_lab_resources(self, resources, published_ports={}):
if pod.metadata.uid not in owners and not is_child:
continue
pod_services[pod.metadata.uid] = []
pod_services_proxy[pod.metadata.uid] = []
statuses = [
status.ready for status in pod.status.container_statuses
]
Expand All @@ -557,6 +559,10 @@ def get_lab_resources(self, resources, published_ports={}):
"pod_ip": pod.status.pod_ip,
"containers": containers,
"services": pod_services[pod.metadata.uid],
# reverse-proxy vhost prefix ("{scheme}://{port}-{svc-name}") for
# each entry in "services", index-aligned; the view appends the
# configured PROXY_DOMAIN and probes it client-side
"services_proxy": pod_services_proxy[pod.metadata.uid],
"phase": pod.status.phase,
"labels": pod_labels,
"more": str(pod),
Expand Down Expand Up @@ -625,6 +631,9 @@ def get_lab_resources(self, resources, published_ports={}):
]
if port.node_port:
pod_services[pod.metadata.uid].append(service_link)
pod_services_proxy[pod.metadata.uid].append(
f"{self.try_get_app(port_name)}{port.port}-{srv.metadata.name}"
)

return labs

Expand Down
18 changes: 18 additions & 0 deletions apps/home/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,20 +441,38 @@ def view_lab_instance(lab_id):
"created": "--",
"expires_at": datetime_from_ts(lab_instance.expiration_ts),
}
# reverse-proxy vhost domain (optional); when set, each web service link
# gets a candidate "https://{port}-{svc-name}.{PROXY_DOMAIN}" URL that the
# page probes client-side and swaps in if the vhost responds
proxy_domain = current_app.config.get("PROXY_DOMAIN", "")
created = None
#for pod in running_labs[(lab_instance.lab_id, owner.uid)]:
for pod in lab_resources:
#if pod["kind"] != "pod":
# continue
if not created or created > pod['created']:
created = pod["created"]
# build the per-service proxy URL, index-aligned with "services"; only
# http(s) services get one (ssh/vnc are left pointing at the NodePort).
# The proxy URL is always https:// regardless of the backend scheme: the
# nginx reverse proxy terminates TLS and translates https->http to the
# NodePort, and an http:// link would be blocked as mixed content on the
# https dashboard page.
services_proxy = []
for prefix in pod.get("services_proxy", []):
if proxy_domain and (prefix.startswith("http://") or prefix.startswith("https://")):
host = prefix.split("://", 1)[1]
services_proxy.append(f"https://{host}.{proxy_domain}")
else:
services_proxy.append("")
lab_dict["resources"].append({
"kind": "pod",
"name": pod["name"],
"display_name": pod["display_name"],
"ready": pod["phase"],
"links": pod["containers"],
"services": pod["services"],
"services_proxy": services_proxy,
"age": pod['age'],
"node_name": pod.get("node_name", "--"),
"pod_ip": pod.get("pod_ip", "--"),
Expand Down
55 changes: 54 additions & 1 deletion apps/templates/pages/lab_instance_view.html
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@ <h3 class="card-title text single-line">{{ _('Lab title: %(title)s / Lab owner:
<td>
<div class="d-flex flex-nowrap">
{% for service in resource['services'] %}
<a class="btn btn-outline-dark mr-1 btn-service" href="{{ service[1] }}" target="_blank" role="button"><i class="fas fa-globe"></i> {{ service[0] }}</a>
{% set proxy_url = resource['services_proxy'][loop.index0] if resource['services_proxy']|length > loop.index0 else '' %}
<a class="btn btn-outline-dark mr-1 btn-service" href="{{ service[1] }}"{% if proxy_url %} data-proxy-url="{{ proxy_url }}"{% endif %} target="_blank" role="button"><i class="fas fa-globe"></i> {{ service[0] }}</a>
{% endfor %}
</div>
</td>
Expand Down Expand Up @@ -521,6 +522,58 @@ <h3 class="card-title">{{ _('Lab Guide') }}</h3>
// here; run immediately instead of waiting for window "load" (which can
// be delayed by images or may have already fired)
loadAnswers();
// ---- reverse-proxy vhost links ----
// Each web service link may carry a candidate reverse-proxy URL
// ("https://{port}-{svc-name}.{PROXY_DOMAIN}"). Probe it and, if the
// vhost is reachable, use it as the actual link; on any failure the
// original NodePort link is left untouched.
//
// The probe uses mode:'no-cors' on purpose. The proxied lab services are
// a different origin than the dashboard and don't emit CORS headers, and
// many require auth (GET / returns 401) -- a CORS-mode probe can't read
// any of those responses and just throws. An opaque no-cors response
// can't expose the status, but that's fine: a reachable vhost (200/302/
// 401/...) resolves the promise, while a hostname with no configured
// vhost is rejected by the reverse proxy's default server ("return 444",
// see scripts/k8s_nginx_revproxy/NGINX_SETUP.md) and makes fetch reject.
// So "promise resolved" == "vhost exists" -> swap.
function probeProxyLink(anchor) {
var proxyUrl = anchor.getAttribute('data-proxy-url');
if (!proxyUrl || typeof fetch !== 'function') {
return;
}
var controller = (typeof AbortController === 'function') ? new AbortController() : null;
var timer = controller ? setTimeout(function () { controller.abort(); }, 5000) : null;
fetch(proxyUrl, {
method: 'GET',
mode: 'no-cors',
signal: controller ? controller.signal : undefined
}).then(function (resp) {
if (timer) { clearTimeout(timer); }
// In mode:'no-cors' a cross-origin response is "opaque": resp.type is
// 'opaque' (or 'opaqueredirect'), resp.status is always 0 and resp.ok
// is always false, so the status-based checks below can't actually
// read it -- the opaque branch is what fires here, and that is the
// whole detection mechanism (a resolved opaque response means the
// vhost exists; a missing vhost is closed by the default server and
// rejects instead). The explicit 2xx/3xx checks are kept anyway:
// they are harmless (opaque still triggers the swap) and keep the
// intent clear / correct should the probe ever be switched back to
// CORS mode where the real status becomes readable.
var isOpaque = resp.type === 'opaque' || resp.type === 'opaqueredirect';
var isSuccess = resp.ok || (resp.status >= 200 && resp.status < 400);
if (isOpaque || isSuccess) {
anchor.setAttribute('href', proxyUrl);
}
}).catch(function () {
if (timer) { clearTimeout(timer); }
// unreachable / rejected by the default server / timed out: keep the
// original NodePort link
});
}
$('.btn-service[data-proxy-url]').each(function () {
probeProxyLink(this);
});
})
// Extends lab expiration
function extendLabExpiration(hours) {
Expand Down
Loading
Loading