diff --git a/Dockerfile b/Dockerfile index 6f62f9f..92bd502 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,6 +61,7 @@ COPY scripts/chaos-containers.lua /usr/local/openresty/nginx/conf/kubeinvaders/c COPY scripts/programming_mode.lua /usr/local/openresty/nginx/conf/kubeinvaders/programming_mode.lua COPY scripts/config_kubeinv.lua /usr/local/openresty/lualib/config_kubeinv.lua COPY scripts/data/codenames.txt /usr/local/openresty/nginx/conf/kubeinvaders/data/codenames.txt +COPY scripts/demo_deploy.lua /usr/local/openresty/nginx/conf/kubeinvaders/demo_deploy.lua # Copy Python helpers COPY scripts/programming_mode /opt/programming_mode/ diff --git a/html/index.html b/html/index.html index 4df6f59..ad32b26 100644 --- a/html/index.html +++ b/html/index.html @@ -12,6 +12,7 @@ + @@ -549,6 +550,20 @@ EOF

Kubernetes Connection

+ +
+ + + +
+ + + Parses the selected context and auto-fills the fields below. Or fill them manually. + + @@ -603,6 +618,27 @@ EOF
+ + + + +
diff --git a/html/js/utils.js b/html/js/utils.js index 3c0ee78..c4e0ebe 100644 --- a/html/js/utils.js +++ b/html/js/utils.js @@ -16,6 +16,183 @@ /* Utility Functions */ +function loadKubeconfigFile(event) { + var file = event.target.files[0]; + if (!file) return; + + var filenameSpan = document.getElementById('kubeconfig_filename'); + var statusDiv = document.getElementById('kubeconfig-load-status'); + var successDiv = document.getElementById('kubeconfig-load-success'); + var errorDiv = document.getElementById('kubeconfig-load-error'); + + if (filenameSpan) filenameSpan.textContent = file.name; + if (statusDiv) statusDiv.style.display = 'none'; + if (successDiv) { successDiv.style.display = 'none'; successDiv.textContent = ''; } + if (errorDiv) { errorDiv.style.display = 'none'; errorDiv.textContent = ''; } + + var reader = new FileReader(); + reader.onload = function(e) { + var kubeconfig; + try { + kubeconfig = jsyaml.load(e.target.result); + } catch (err) { + showKubeconfigStatus('Failed to parse KUBECONFIG: ' + err.message, true); + return; + } + + if (!kubeconfig || typeof kubeconfig !== 'object') { + showKubeconfigStatus('Invalid KUBECONFIG file.', true); + return; + } + + var currentContextName = kubeconfig['current-context']; + var contexts = kubeconfig.contexts || []; + var contextEntry = contexts.find(function(c) { return c.name === currentContextName; }) || contexts[0]; + + if (!contextEntry) { + showKubeconfigStatus('No context found in KUBECONFIG.', true); + return; + } + + var ctx = contextEntry.context || {}; + var clusterName = ctx.cluster; + var userName = ctx.user; + var namespace = ctx.namespace || ''; + + var clusters = kubeconfig.clusters || []; + var clusterEntry = clusters.find(function(c) { return c.name === clusterName; }); + var clusterData = clusterEntry ? (clusterEntry.cluster || {}) : {}; + + var users = kubeconfig.users || []; + var userEntry = users.find(function(u) { return u.name === userName; }); + var userData = userEntry ? (userEntry.user || {}) : {}; + + var server = clusterData.server || ''; + var caCertB64 = clusterData['certificate-authority-data'] || ''; + var caCertFilePath = clusterData['certificate-authority'] || ''; + var token = userData.token || userData['token-data'] || ''; + var usesClientCert = !token && ( + userData['client-certificate'] || userData['client-certificate-data'] || + userData['exec'] + ); + + var caCert = ''; + if (caCertB64) { + try { + caCert = atob(caCertB64); + } catch (_) { + caCert = caCertB64; + } + } + + var endpointInput = document.getElementById('k8s_api_endpoint'); + var tokenInput = document.getElementById('k8s_token'); + var caCertInput = document.getElementById('k8s_ca_cert'); + var namespacesInput = document.getElementById('k8s_namespaces'); + + if (endpointInput && server) endpointInput.value = server; + if (tokenInput && token) { tokenInput.value = token; localStorage.setItem('k8s_token', token); } + if (caCertInput) caCertInput.value = caCert; + if (namespacesInput && namespace && !namespacesInput.value) namespacesInput.value = namespace; + + if (server) localStorage.setItem('k8s_api_endpoint', server); + if (caCert) localStorage.setItem('k8s_ca_cert', caCert); + + if (usesClientCert) { + var ns = namespace || 'default'; + var cmds = [ + 'kubectl create serviceaccount kubeinvaders -n ' + ns, + 'kubectl create clusterrolebinding kubeinvaders \\', + ' --clusterrole=cluster-admin \\', + ' --serviceaccount=' + ns + ':kubeinvaders', + 'kubectl create token kubeinvaders -n ' + ns + ]; + var caCertWarning = (!caCert && caCertFilePath) + ? '

CA certificate: your kubeconfig references a local file (' + + escapeHtml(caCertFilePath) + ') that cannot be read by the browser. ' + + 'Paste its contents manually into the CA Certificate field above.' + : ''; + var statusDiv = document.getElementById('kubeconfig-load-status'); + var errorDiv = document.getElementById('kubeconfig-load-error'); + var successDiv = document.getElementById('kubeconfig-load-success'); + if (statusDiv) statusDiv.style.display = 'block'; + if (successDiv) successDiv.style.display = 'none'; + if (errorDiv) { + errorDiv.style.display = 'block'; + errorDiv.innerHTML = + 'Endpoint loaded (context: ' + contextEntry.name + '), but no token found.
' + + 'This context uses client-certificate auth, which is not supported directly.
' + + 'We suggest running these commands to create a cluster-admin service account token:

' + + '' + + cmds.map(function(c) { return escapeHtml(c); }).join('\n') + + '' + + '' + + caCertWarning; + } + return; + } + + var loaded = []; + if (server) loaded.push('endpoint'); + if (token) loaded.push('token'); + if (caCert) loaded.push('CA cert'); + var contextLabel = contextEntry.name ? ' (context: ' + contextEntry.name + ')' : ''; + var msg = 'Loaded ' + loaded.join(', ') + contextLabel + '.'; + if (!caCert && caCertFilePath) { + msg += '\nCA certificate references a local file (' + caCertFilePath + ') — paste it manually into the CA Certificate field.'; + } + + showKubeconfigStatus(msg, false); + }; + reader.readAsText(file); +} + +function showKubeconfigStatus(message, isError) { + var statusDiv = document.getElementById('kubeconfig-load-status'); + var successDiv = document.getElementById('kubeconfig-load-success'); + var errorDiv = document.getElementById('kubeconfig-load-error'); + if (!statusDiv) return; + statusDiv.style.display = 'block'; + if (isError) { + if (errorDiv) { errorDiv.style.display = 'block'; errorDiv.textContent = message; } + if (successDiv) successDiv.style.display = 'none'; + } else { + if (successDiv) { successDiv.style.display = 'block'; successDiv.textContent = message; } + if (errorDiv) errorDiv.style.display = 'none'; + } +} + +function escapeHtml(str) { + return str.replace(/&/g, '&').replace(//g, '>'); +} + +function escapeAttr(str) { + return str.replace(/&/g, '&').replace(/"/g, '"'); +} + +function copyKubeconfigCommands(btn) { + var commands = btn.getAttribute('data-commands'); + navigator.clipboard.writeText(commands).then(function() { + var orig = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(function() { btn.textContent = orig; }, 2000); + }).catch(function() { + var ta = document.createElement('textarea'); + ta.value = commands; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + var orig = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(function() { btn.textContent = orig; }, 2000); + }); +} + function parseNamespacesInput(input) { if (!input) { return []; @@ -336,6 +513,7 @@ function saveK8sConnection() { } showConnectionStatus('', false, 'k8s-save', 3000); + showDemoDeployBlock(); return true; }) .catch(function (error) { @@ -344,6 +522,64 @@ function saveK8sConnection() { }); } +function showDemoDeployBlock() { + var block = document.getElementById('demo-deploy-block'); + if (block) { + block.style.display = 'block'; + } +} + +function deployDemoResources() { + var btn = document.getElementById('demo-deploy-btn'); + var statusDiv = document.getElementById('demo-deploy-status'); + var successDiv = document.getElementById('demo-deploy-success'); + var errorDiv = document.getElementById('demo-deploy-error'); + + if (btn) { + btn.disabled = true; + btn.textContent = 'Deploying...'; + } + + var url = appendK8sTargetParam('/kube/demo/deploy'); + var oReq = new XMLHttpRequest(); + oReq.open('POST', url, true); + applyK8sConnectionHeaders(oReq); + oReq.timeout = 30000; + + oReq.onreadystatechange = function () { + if (this.readyState !== XMLHttpRequest.DONE) return; + + if (btn) { + btn.disabled = false; + btn.textContent = 'Deploy ns-1 & ns-2 (10 pods each)'; + } + + statusDiv.style.display = 'block'; + var payload = null; + try { payload = JSON.parse(this.responseText); } catch (e) {} + + if (this.status === 200 || this.status === 207) { + var msg = (payload && payload.message) ? payload.message : 'Deployed successfully.'; + if (payload && payload.errors && payload.errors.length > 0) { + errorDiv.style.display = 'block'; + successDiv.style.display = 'none'; + errorDiv.textContent = 'Partial failure: ' + payload.errors.join('; '); + } else { + successDiv.style.display = 'block'; + errorDiv.style.display = 'none'; + successDiv.textContent = '✅ ' + msg; + } + } else { + var errMsg = (payload && payload.error) ? payload.error : this.responseText; + errorDiv.style.display = 'block'; + successDiv.style.display = 'none'; + errorDiv.textContent = '❌ Deploy failed: ' + errMsg; + } + }; + + oReq.send(); +} + document.addEventListener("DOMContentLoaded", function() { // Migrate old localStorage key name used in earlier versions var legacyK8sUrl = localStorage.getItem("k8s_url"); @@ -386,6 +622,10 @@ document.addEventListener("DOMContentLoaded", function() { configured_namespaces = parseNamespacesInput(storedNamespaces || ""); + if (storedK8sUrl && storedToken) { + showDemoDeployBlock(); + } + setTimeout(function() { var splashScreen = document.getElementById("splash-screen"); var mainGameDiv = document.getElementById("main-game-div"); diff --git a/nginx/KubeInvaders.conf b/nginx/KubeInvaders.conf index 3199b68..a136c61 100644 --- a/nginx/KubeInvaders.conf +++ b/nginx/KubeInvaders.conf @@ -62,6 +62,10 @@ server { sub_filter selected_env_vars_placeholder $selected_env_vars; } + location /kube/demo/deploy { + access_by_lua_file "/usr/local/openresty/nginx/conf/kubeinvaders/demo_deploy.lua"; + } + location /kube/ingresses { access_by_lua_file "/usr/local/openresty/nginx/conf/kubeinvaders/ingress.lua"; } diff --git a/scripts/demo_deploy.lua b/scripts/demo_deploy.lua new file mode 100644 index 0000000..39b5e1e --- /dev/null +++ b/scripts/demo_deploy.lua @@ -0,0 +1,147 @@ +local https = require "ssl.https" +local ltn12 = require "ltn12" +local json = require "lunajson" + +local arg = ngx.req.get_uri_args() +local req_headers = ngx.req.get_headers() + +ngx.header['Content-Type'] = 'application/json' +ngx.header['Access-Control-Allow-Origin'] = '*' +ngx.header['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' +ngx.header['Access-Control-Allow-Headers'] = 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,X-K8S-Target,X-K8S-Token,X-K8S-CA-CERT-B64' +ngx.header['Access-Control-Expose-Headers'] = 'Content-Length,Content-Range' + +if ngx.var.request_method == "OPTIONS" then + ngx.status = 204 + return +end + +local k8s_url = "" +local kube_host = os.getenv("KUBERNETES_SERVICE_HOST") +local kube_port = os.getenv("KUBERNETES_SERVICE_PORT_HTTPS") +local endpoint = os.getenv("ENDPOINT") + +if kube_host and kube_host ~= "" then + local port_suffix = kube_port and kube_port ~= "" and (":" .. kube_port) or "" + k8s_url = "https://" .. kube_host .. port_suffix +else + k8s_url = endpoint or "" +end + +local target = arg['target'] or req_headers["x-k8s-target"] or req_headers["X-K8S-Target"] +if target and target ~= "" then + if not string.match(target, "^https?://") then + target = "https://" .. target + end + k8s_url = string.gsub(target, "/+$", "") +end + +if k8s_url == "" then + ngx.status = 500 + ngx.say(json.encode({ok = false, error = "Missing Kubernetes endpoint"})) + return +end + +if not string.match(k8s_url, "^https?://") then + k8s_url = "https://" .. k8s_url +end +k8s_url = string.gsub(k8s_url, "/+$", "") + +local header_token = req_headers["x-k8s-token"] or req_headers["X-K8S-Token"] +local token = header_token and header_token ~= "" and header_token or tostring(os.getenv("TOKEN") or "") +if token == "" then + local f = io.open("/var/run/secrets/kubernetes.io/serviceaccount/token", "r") + if f then + token = f:read("*a") or "" + token = token:gsub("%s+$", "") + f:close() + end +end + +if token == "" then + ngx.status = 500 + ngx.say(json.encode({ok = false, error = "Missing Kubernetes API token"})) + return +end + +local ca_cert_b64 = req_headers["x-k8s-ca-cert-b64"] or req_headers["X-K8S-CA-CERT-B64"] +local ca_cert = nil +if ca_cert_b64 and ca_cert_b64 ~= "" then + ca_cert = ngx.decode_base64(ca_cert_b64) +end + +local disable_tls_env = string.lower(tostring(os.getenv("DISABLE_TLS") or "false")) +local disable_tls = disable_tls_env == "true" or disable_tls_env == "1" or disable_tls_env == "yes" + +local ca_file_path = "/tmp/kubeinv-demo-ca.crt" +if not disable_tls and ca_cert and ca_cert ~= "" then + local ca_file = io.open(ca_file_path, "w") + if ca_file then + ca_file:write(ca_cert) + ca_file:close() + end +end + +local function k8s_request(url, method, body) + local resp = {} + local headers = { + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + ["Authorization"] = "Bearer " .. token, + } + local opts = { + url = url, + headers = headers, + method = method, + verify = disable_tls and "none" or "peer", + sink = ltn12.sink.table(resp), + } + if body and body ~= "" then + headers["Content-Length"] = tostring(#body) + opts.source = ltn12.source.string(body) + end + if not disable_tls and ca_cert and ca_cert ~= "" then + opts.cafile = ca_file_path + end + local ok, status_code, _, _ = https.request(opts) + return ok, status_code, table.concat(resp) +end + +local ns_body_fmt = '{"apiVersion":"v1","kind":"Namespace","metadata":{"name":"%s","labels":{"app":"kubeinvaders-demo"}}}' +local deploy_body_fmt = '{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"kubeinvaders-demo","namespace":"%s","labels":{"app":"kubeinvaders-demo"}},"spec":{"replicas":10,"selector":{"matchLabels":{"app":"kubeinvaders-demo"}},"template":{"metadata":{"labels":{"app":"kubeinvaders-demo"}},"spec":{"containers":[{"name":"nginx","image":"nginx:alpine","resources":{"requests":{"memory":"32Mi","cpu":"10m"},"limits":{"memory":"64Mi","cpu":"50m"}}}]}}}}' + +local created = {} +local errors = {} + +for _, ns in ipairs({"ns-1", "ns-2"}) do + local ns_body = string.format(ns_body_fmt, ns) + local ok, status, _ = k8s_request(k8s_url .. "/api/v1/namespaces", "POST", ns_body) + + if not ok then + table.insert(errors, "namespace " .. ns .. ": request failed") + elseif status ~= 200 and status ~= 201 and status ~= 409 then + table.insert(errors, "namespace " .. ns .. ": HTTP " .. tostring(status)) + else + local deploy_body = string.format(deploy_body_fmt, ns) + local dok, dstatus, _ = k8s_request( + k8s_url .. "/apis/apps/v1/namespaces/" .. ns .. "/deployments", + "POST", + deploy_body + ) + if not dok then + table.insert(errors, "deployment in " .. ns .. ": request failed") + elseif dstatus ~= 200 and dstatus ~= 201 and dstatus ~= 409 then + table.insert(errors, "deployment in " .. ns .. ": HTTP " .. tostring(dstatus)) + else + table.insert(created, ns) + end + end +end + +if #errors == 0 then + ngx.status = 200 + ngx.say(json.encode({ok = true, created = created, message = "Deployed ns-1 and ns-2 with 10 nginx pods each"})) +else + ngx.status = 207 + ngx.say(json.encode({ok = #created > 0, created = created, errors = errors})) +end