diff --git a/resources/charts/namespaces/templates/namespace.yaml b/resources/charts/namespaces/templates/namespace.yaml index 5e01eebed..5709cf499 100644 --- a/resources/charts/namespaces/templates/namespace.yaml +++ b/resources/charts/namespaces/templates/namespace.yaml @@ -2,3 +2,5 @@ apiVersion: v1 kind: Namespace metadata: name: {{ .Values.namespaceName | default .Release.Name }} + labels: + type: {{ .Values.type }} diff --git a/resources/charts/namespaces/values.yaml b/resources/charts/namespaces/values.yaml index 61f946879..99d411363 100644 --- a/resources/charts/namespaces/values.yaml +++ b/resources/charts/namespaces/values.yaml @@ -1,3 +1,4 @@ +type: "assets" users: - name: warnet-user roles: @@ -37,4 +38,4 @@ roles: verbs: ["get", "list"] - apiGroups: [""] resources: ["events"] - verbs: ["get"] \ No newline at end of file + verbs: ["get"] diff --git a/resources/namespaces/two_namespaces_two_users/namespace-defaults.yaml b/resources/namespaces/two_namespaces_two_users/namespace-defaults.yaml index 91ac2fc67..75cc8e42c 100644 --- a/resources/namespaces/two_namespaces_two_users/namespace-defaults.yaml +++ b/resources/namespaces/two_namespaces_two_users/namespace-defaults.yaml @@ -3,14 +3,16 @@ users: roles: - pod-viewer - pod-manager -roles: - - name: pod-viewer - rules: - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch"] - - name: pod-manager - rules: - - apiGroups: [""] - resources: ["pods", "configmaps"] - verbs: ["get", "list", "watch", "create", "update", "delete"] +# the pod-viewer and pod-manager roles are the default +# roles defined in values.yaml for the namespaces charts +# +# if you need a different set of roles for a particular namespaces +# deployment, you can override values.yaml by providing your own +# role definitions below +# +# roles: +# - name: my-custom-role +# rules: +# - apiGroups: "" +# resources: "" +# verbs: "" diff --git a/resources/namespaces/two_namespaces_two_users/namespaces.yaml b/resources/namespaces/two_namespaces_two_users/namespaces.yaml index 4172657b8..542456ef6 100644 --- a/resources/namespaces/two_namespaces_two_users/namespaces.yaml +++ b/resources/namespaces/two_namespaces_two_users/namespaces.yaml @@ -1,5 +1,5 @@ namespaces: - - name: warnet-red-team + - name: wargames-red-team users: - name: alice roles: @@ -8,42 +8,7 @@ namespaces: roles: - pod-viewer - pod-manager - roles: - - name: pod-viewer - rules: - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["pods/log", "pods/exec", "pods/attach", "pods/portforward"] - verbs: ["get"] - - apiGroups: [""] - resources: ["configmaps", "secrets"] - verbs: ["get"] - - apiGroups: [""] - resources: ["persistentvolumeclaims"] - verbs: ["get", "list"] - - apiGroups: [""] - resources: ["events"] - verbs: ["get"] - - name: pod-manager - rules: - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch", "create", "delete", "update"] - - apiGroups: [""] - resources: ["pods/log", "pods/exec", "pods/attach", "pods/portforward"] - verbs: ["get", "create"] - - apiGroups: [""] - resources: ["configmaps", "secrets"] - verbs: ["get", "create"] - - apiGroups: [""] - resources: ["persistentvolumeclaims"] - verbs: ["get", "list"] - - apiGroups: [""] - resources: ["events"] - verbs: ["get"] - - name: warnet-blue-team + - name: wargames-blue-team users: - name: mallory roles: @@ -52,38 +17,3 @@ namespaces: roles: - pod-viewer - pod-manager - roles: - - name: pod-viewer - rules: - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["pods/log", "pods/exec", "pods/attach", "pods/portforward"] - verbs: ["get"] - - apiGroups: [""] - resources: ["configmaps", "secrets"] - verbs: ["get"] - - apiGroups: [""] - resources: ["persistentvolumeclaims"] - verbs: ["get", "list"] - - apiGroups: [""] - resources: ["events"] - verbs: ["get"] - - name: pod-manager - rules: - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch", "create", "delete", "update"] - - apiGroups: [""] - resources: ["pods/log", "pods/exec", "pods/attach", "pods/portforward"] - verbs: ["get", "create"] - - apiGroups: [""] - resources: ["configmaps", "secrets"] - verbs: ["get", "create"] - - apiGroups: [""] - resources: ["persistentvolumeclaims"] - verbs: ["get", "list"] - - apiGroups: [""] - resources: ["events"] - verbs: ["get"] diff --git a/src/warnet/admin.py b/src/warnet/admin.py index f194e16bd..a60f66753 100644 --- a/src/warnet/admin.py +++ b/src/warnet/admin.py @@ -5,8 +5,10 @@ from rich import print as richprint from .constants import NETWORK_DIR +from .k8s import get_kubeconfig_value, get_namespaces_by_prefix, get_service_accounts_in_namespace from .namespaces import copy_namespaces_defaults, namespaces from .network import copy_network_defaults +from .process import run_command @click.group(name="admin", hidden=True) @@ -33,3 +35,93 @@ def init(): f"[green]Copied network and namespace example files to {Path(current_dir) / NETWORK_DIR.name}[/green]" ) richprint(f"[green]Created warnet project structure in {current_dir}[/green]") + + +@admin.command() +@click.argument("prefix", type=str, required=True) +@click.option( + "--kubeconfig-dir", + default="kubeconfigs", + help="Directory to store kubeconfig files (default: kubeconfigs)", +) +@click.option( + "--token-duration", + default=172800, + type=int, + help="Duration of the token in seconds (default: 48 hours)", +) +def create_kubeconfigs(prefix: str, kubeconfig_dir, token_duration): + """Create kubeconfig files for all ServiceAccounts in warnet team namespaces starting with .""" + kubeconfig_dir = os.path.expanduser(kubeconfig_dir) + + cluster_name = get_kubeconfig_value("{.clusters[0].name}") + cluster_server = get_kubeconfig_value("{.clusters[0].cluster.server}") + cluster_ca = get_kubeconfig_value("{.clusters[0].cluster.certificate-authority-data}") + + os.makedirs(kubeconfig_dir, exist_ok=True) + + # Get all namespaces that start with prefix + # This assumes when deploying multiple namespacs for the purpose of team games, all namespaces start with a prefix, + # e.g., tabconf-wargames-*. Currently, this is a bit brittle, but we can improve on this in the future + # by automatically applying a TEAM_PREFIX when creating the get_warnet_namespaces + # TODO: choose a prefix convention and have it managed by the helm charts instead of requiring the + # admin user to pipe through the correct string in multiple places. Another would be to use + # labels instead of namespace naming conventions + warnet_namespaces = get_namespaces_by_prefix(prefix) + + for namespace in warnet_namespaces: + click.echo(f"Processing namespace: {namespace}") + service_accounts = get_service_accounts_in_namespace(namespace) + + for sa in service_accounts: + # Create a token for the ServiceAccount with specified duration + command = f"kubectl create token {sa} -n {namespace} --duration={token_duration}s" + try: + token = run_command(command) + except Exception as e: + click.echo( + f"Failed to create token for ServiceAccount {sa} in namespace {namespace}. Error: {str(e)}. Skipping..." + ) + continue + + # Create a kubeconfig file for the user + kubeconfig_file = os.path.join(kubeconfig_dir, f"{sa}-{namespace}-kubeconfig") + + # TODO: move yaml out of python code to resources/manifests/ + # + # might not be worth it since we are just reading the yaml to then create a bunch of values and its not + # actually used to deploy anything into the cluster + # Then benefit would be making this code a bit cleaner and easy to follow, fwiw + kubeconfig_content = f"""apiVersion: v1 +kind: Config +clusters: +- name: {cluster_name} + cluster: + server: {cluster_server} + certificate-authority-data: {cluster_ca} +users: +- name: {sa} + user: + token: {token} +contexts: +- name: {sa}-{namespace} + context: + cluster: {cluster_name} + namespace: {namespace} + user: {sa} +current-context: {sa}-{namespace} +""" + with open(kubeconfig_file, "w") as f: + f.write(kubeconfig_content) + + click.echo(f" Created kubeconfig file for {sa}: {kubeconfig_file}") + + click.echo("---") + click.echo( + f"All kubeconfig files have been created in the '{kubeconfig_dir}' directory with a duration of {token_duration} seconds." + ) + click.echo("Distribute these files to the respective users.") + click.echo( + "Users can then use by running `warnet auth ` or with kubectl by specifying the --kubeconfig flag or by setting the KUBECONFIG environment variable." + ) + click.echo(f"Note: The tokens will expire after {token_duration} seconds.") diff --git a/src/warnet/constants.py b/src/warnet/constants.py index c01e8c2b4..f3b36f71d 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -15,6 +15,7 @@ LOGGING_NAMESPACE = "warnet-logging" INGRESS_NAMESPACE = "ingress" HELM_COMMAND = "helm upgrade --install --create-namespace" +WARNET_ASSETS = "assets" # Directories and files for non-python assets, e.g., helm charts, example scenarios, default configs SRC_DIR = files("warnet") diff --git a/src/warnet/control.py b/src/warnet/control.py index 782764cd9..ccc9c186e 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -132,8 +132,9 @@ def delete_pod(pod_name, namespace): # Delete remaining pods pods = get_pods() - for pod in pods.items: - futures.append(executor.submit(delete_pod, pod.metadata.name, pod.metadata.namespace)) + for pod_list in pods: + for pod in pod_list.items: + futures.append(executor.submit(delete_pod, pod.metadata.name, pod.metadata.namespace)) # Wait for all tasks to complete and print results for future in as_completed(futures): @@ -159,9 +160,10 @@ def get_active_network(namespace): @click.command(context_settings={"ignore_unknown_options": True}) +@click.option("--namespace", "-n", type=str, help="Namespace to run scenario in (overrides the current namespace in kubectl)") @click.argument("scenario_file", type=click.Path(exists=True, file_okay=True, dir_okay=False)) @click.argument("additional_args", nargs=-1, type=click.UNPROCESSED) -def run(scenario_file: str, additional_args: tuple[str]): +def run(namespace: str, scenario_file: str, additional_args: tuple[str]): """ Run a scenario from a file. Pass `-- --help` to get individual scenario help @@ -173,7 +175,7 @@ def run(scenario_file: str, additional_args: tuple[str]): scenario_data = base64.b64encode(file.read()).decode() name = f"commander-{scenario_name.replace('_', '')}-{int(time.time())}" - namespace = get_default_namespace() + ns = namespace if namespace else get_default_namespace() tankpods = get_mission("tank") tanks = [ { @@ -198,7 +200,7 @@ def run(scenario_file: str, additional_args: tuple[str]): "upgrade", "--install", "--namespace", - namespace, + ns, "--set", f"fullnameOverride={name}", "--set", diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index c175dd6d9..3cfbb9fc3 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -42,19 +42,23 @@ def validate_directory(ctx, param, value): callback=validate_directory, ) @click.option("--debug", is_flag=True) -def deploy(directory, debug): +@click.option("--namespace", "-n", type=str, help="Namespace to deploy network into (overrides the current namespace in kubectl)") +def deploy(directory, debug, namespace): """Deploy a warnet with topology loaded from """ directory = Path(directory) if (directory / NETWORK_FILE).exists(): dl = deploy_logging_stack(directory, debug) - deploy_network(directory, debug) + deploy_network(directory, namespace, debug) df = deploy_fork_observer(directory, debug) if dl | df: deploy_ingress(debug) deploy_caddy(directory, debug) elif (directory / NAMESPACES_FILE).exists(): - deploy_namespaces(directory) + if namespace: + click.echo("Cannot specify a --namespace when deploying a namespaces chart.") + else: + deploy_namespaces(directory) else: click.echo( "Error: Neither network.yaml nor namespaces.yaml found in the specified directory." @@ -189,14 +193,14 @@ def deploy_fork_observer(directory: Path, debug: bool) -> bool: return True -def deploy_network(directory: Path, debug: bool = False): +def deploy_network(directory: Path, namespace_override: str, debug: bool = False): network_file_path = directory / NETWORK_FILE defaults_file_path = directory / DEFAULTS_FILE with network_file_path.open() as f: network_file = yaml.safe_load(f) - namespace = get_default_namespace() + namespace = namespace_override if namespace_override else get_default_namespace() for node in network_file["nodes"]: click.echo(f"Deploying node: {node.get('name')}") @@ -235,14 +239,6 @@ def deploy_namespaces(directory: Path): with namespaces_file_path.open() as f: namespaces_file = yaml.safe_load(f) - names = [n.get("name") for n in namespaces_file["namespaces"]] - for n in names: - if not n.startswith("warnet-"): - click.echo( - f"Failed to create namespace: {n}. Namespaces must start with a 'warnet-' prefix." - ) - return - for namespace in namespaces_file["namespaces"]: click.echo(f"Deploying namespace: {namespace.get('name')}") try: diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index 37f5d38f1..3f365e287 100644 --- a/src/warnet/k8s.py +++ b/src/warnet/k8s.py @@ -6,7 +6,8 @@ import yaml from kubernetes import client, config, watch -from kubernetes.client.models import CoreV1Event, V1PodList +from kubernetes.client.models import CoreV1Event, V1PodList, V1NamespaceList, V1Pod +from kubernetes.client.rest import ApiException from kubernetes.dynamic import DynamicClient from kubernetes.stream import stream @@ -16,6 +17,7 @@ INGRESS_NAMESPACE, KUBECONFIG, LOGGING_NAMESPACE, + WARNET_ASSETS, ) from .process import run_command, stream_command @@ -32,19 +34,23 @@ def get_dynamic_client() -> DynamicClient: def get_pods() -> V1PodList: sclient = get_static_client() - try: - pod_list: V1PodList = sclient.list_namespaced_pod(get_default_namespace()) - except Exception as e: - raise e - return pod_list + pods = [] + namespaces: V1NamespaceList = get_namespaces_by_warnet_type(WARNET_ASSETS) + for ns in namespaces.items: + try: + pods.append(sclient.list_namespaced_pod(ns.metadata.name)) + except Exception as e: + raise e + return pods def get_mission(mission: str) -> list[V1PodList]: pods = get_pods() crew = [] - for pod in pods.items: - if "mission" in pod.metadata.labels and pod.metadata.labels["mission"] == mission: - crew.append(pod) + for pod_list in pods: + for pod in pod_list.items: + if "mission" in pod.metadata.labels and pod.metadata.labels["mission"] == mission: + crew.append(pod) return crew @@ -282,3 +288,33 @@ def get_ingress_ip_or_host(): except Exception as e: print(f"Error getting ingress IP: {e}") return None + + +def get_kubeconfig_value(jsonpath): + command = f"kubectl config view --minify -o jsonpath={jsonpath}" + return run_command(command) + + +def get_namespaces_by_prefix(prefix: str): + """ + Get all namespaces beginning with `prefix`. Returns empty list of no namespaces with the specified prefix are found. + """ + command = "kubectl get namespaces -o jsonpath={.items[*].metadata.name}" + namespaces = run_command(command).split() + return [ns for ns in namespaces if ns.startswith(prefix)] + + +def get_service_accounts_in_namespace(namespace): + """ + Get all service accounts in a namespace. Returns an empty list if no service accounts are found in the specified namespace. + """ + command = f"kubectl get serviceaccounts -n {namespace} -o jsonpath={{.items[*].metadata.name}}" + # skip the default service account created by k8s + service_accounts = run_command(command).split() + return [sa for sa in service_accounts if sa != "default"] + + +def get_namespaces_by_warnet_type(warnet_type: str) -> list[V1NamespaceList]: + sclient = get_static_client() + namespaces = sclient.list_namespace(label_selector=f"type={warnet_type}") + return namespaces