From 7daf980543adacd1e405f609184540315f05422b Mon Sep 17 00:00:00 2001 From: Tasko Olevski Date: Wed, 18 Jan 2023 14:18:04 +0100 Subject: [PATCH 1/4] feat: ssh access to sessions --- chartpress.yaml | 6 + helm-chart/renku-notebooks/templates/ssh.yaml | 109 ++++++++++++++++++ .../templates/statefulset.yaml | 6 + .../renku-notebooks/templates/test.yaml | 6 + helm-chart/renku-notebooks/values.yaml | 32 +++++ renku_notebooks/api/amalthea_patches/ssh.py | 62 ++++++++++ renku_notebooks/api/classes/server.py | 2 + renku_notebooks/config/__init__.py | 1 + renku_notebooks/config/dynamic.py | 15 +++ ssh-jump-host/Dockerfile | 19 +++ ssh-jump-host/sshd_config | 23 ++++ 11 files changed, 281 insertions(+) create mode 100644 helm-chart/renku-notebooks/templates/ssh.yaml create mode 100644 renku_notebooks/api/amalthea_patches/ssh.py create mode 100644 ssh-jump-host/Dockerfile create mode 100644 ssh-jump-host/sshd_config diff --git a/chartpress.yaml b/chartpress.yaml index e7b9aa6d1..3edde7e6c 100644 --- a/chartpress.yaml +++ b/chartpress.yaml @@ -46,3 +46,9 @@ charts: valuesPath: k8sWatcher.image paths: - k8s-watcher + ssh-jump-host: + contextPath: ssh-jump-host + dockerfilePath: ssh-jump-host/Dockerfile + valuesPath: ssh.image + paths: + - ssh-jump-host diff --git a/helm-chart/renku-notebooks/templates/ssh.yaml b/helm-chart/renku-notebooks/templates/ssh.yaml new file mode 100644 index 000000000..ca7d23e84 --- /dev/null +++ b/helm-chart/renku-notebooks/templates/ssh.yaml @@ -0,0 +1,109 @@ +{{- if .Values.ssh.enabled }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ template "notebooks.fullname" . }}-ssh + labels: + app: {{ template "notebooks.name" . }}-ssh + chart: {{ template "notebooks.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + {{- if not .Values.ssh.autoscaling.enabled }} + replicas: {{ .Values.ssh.replicaCount }} + {{- end }} + selector: + matchLabels: + app: {{ template "notebooks.name" . }}-ssh + release: {{ .Release.Name }} + template: + metadata: + labels: + app: {{ template "notebooks.name" . }}-ssh + chart: {{ template "notebooks.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ if .Values.rbac.create }}"{{ template "notebooks.fullname" . }}"{{ else }}"{{ .Values.rbac.serviceAccountName }}"{{ end }} + securityContext: + fsGroup: 1000 + containers: + - name: ssh + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1000 + image: "{{ .Values.ssh.image.repository }}:{{ .Values.ssh.image.tag }}" + imagePullPolicy: {{ .Values.ssh.image.pullPolicy }} + ports: + - name: ssh + containerPort: 2022 + protocol: TCP + resources: + {{- toYaml .Values.ssh.resources | nindent 12 }} + {{- if not (kindIs "invalid" .Values.ssh.hostKeySecret) }} + volumeMounts: + - name: ssh-host-key + mountPath: /opt/ssh/ssh_host_keys + readOnly: true + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if not (kindIs "invalid" .Values.ssh.hostKeySecret) }} + volumes: + - name: ssh-host-key + secret: + secretName: {{ .Values.ssh.hostKeySecret | quote }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ template "notebooks.fullname" . }}-ssh + labels: + app: {{ template "notebooks.name" . }}-ssh + chart: {{ template "notebooks.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + type: {{ .Values.service.type }} + ports: + - name: ssh + {{- if eq .Values.ssh.service.type "NodePort" }} + nodePort: {{ .Values.ssh.service.port }} + {{- end }} + port: {{ .Values.ssh.service.port }} + protocol: TCP + targetPort: ssh + selector: + app: {{ template "notebooks.name" . }}-ssh + release: {{ .Release.Name }} +--- +apiVersion: autoscaling/v1 +kind: HorizontalPodAutoscaler +metadata: + name: {{ template "notebooks.fullname" . }}-ssh +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ template "notebooks.fullname" . }}-ssh + minReplicas: {{ .Values.ssh.autoscaling.minReplicas }} + maxReplicas: {{ .Values.ssh.autoscaling.maxReplicas }} + targetCPUUtilizationPercentage: {{ .Values.ssh.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/helm-chart/renku-notebooks/templates/statefulset.yaml b/helm-chart/renku-notebooks/templates/statefulset.yaml index f1cbc0d4f..9622389bb 100644 --- a/helm-chart/renku-notebooks/templates/statefulset.yaml +++ b/helm-chart/renku-notebooks/templates/statefulset.yaml @@ -182,6 +182,12 @@ spec: - name: NB_KEYCLOAK_REALM value: {{ .Values.global.keycloak.realm | quote }} {{ end }} + - name: NB_SESSIONS__SSH__ENABLED + value: {{ .Values.ssh.enabled | quote }} + {{- if not (kindIs "invalid" .Values.ssh.hostKeySecret) }} + - name: NB_SESSIONS__SSH__HOST_KEY_SECRET + value: {{ .Values.ssh.hostKeySecret | quote }} + {{- end }} ports: - name: http containerPort: 8000 diff --git a/helm-chart/renku-notebooks/templates/test.yaml b/helm-chart/renku-notebooks/templates/test.yaml index 939fbf591..f04fe4020 100644 --- a/helm-chart/renku-notebooks/templates/test.yaml +++ b/helm-chart/renku-notebooks/templates/test.yaml @@ -105,6 +105,12 @@ spec: - name: NB_KEYCLOAK_REALM value: {{ $.Values.global.keycloak.realm | quote }} {{ end }} + - name: NB_SESSIONS__SSH__ENABLED + value: {{ $.Values.ssh.enabled | quote }} + {{- if not (kindIs "invalid" $.Values.ssh.hostKeySecret) }} + - name: NB_SESSIONS__SSH__HOST_KEY_SECRET + value: {{ $.Values.ssh.hostKeySecret | quote }} + {{- end }} command: - poetry - run diff --git a/helm-chart/renku-notebooks/values.yaml b/helm-chart/renku-notebooks/values.yaml index 877b87305..e24fb010f 100644 --- a/helm-chart/renku-notebooks/values.yaml +++ b/helm-chart/renku-notebooks/values.yaml @@ -335,3 +335,35 @@ k8sWatcher: targetCPUUtilizationPercentage: 50 minReplicas: 2 maxReplicas: 5 + +ssh: + enabled: true + image: + repository: renku/renku-notebooks-ssh + tag: "latest" + pullPolicy: IfNotPresent + resources: {} + replicaCount: 1 + service: + type: ClusterIP + port: 22 + autoscaling: + enabled: false + targetCPUUtilizationPercentage: 50 + minReplicas: 1 + maxReplicas: 3 + ## If defined the keys in the secret will be mounted as SSH host keys. + ## This is useful to make sure that the host can be properly recognized + ## when connecting to a session. If left unset then the host keys are + ## likely to change causing ssh connections to fail and require removing the + ## old keys from ~/.ssh/known_hosts. Therefore it is reccomended that this is + ## set for production. The required keys are: + ## - ssh_host_dsa_key + ## - ssh_host_dsa_key.pub + ## - ssh_host_rsa_key + ## - ssh_host_rsa_key.pub + ## - ssh_host_ecdsa_key + ## - ssh_host_ecdsa_key.pub + ## - ssh_host_ed25519_key + ## - ssh_host_ed25519_key.pub + hostKeySecret: diff --git a/renku_notebooks/api/amalthea_patches/ssh.py b/renku_notebooks/api/amalthea_patches/ssh.py new file mode 100644 index 000000000..bc7a9599c --- /dev/null +++ b/renku_notebooks/api/amalthea_patches/ssh.py @@ -0,0 +1,62 @@ +from typing import Any, Dict, List +from ...config import config + + +def main() -> List[Dict[str, Any]]: + if not config.sessions.ssh.enabled: + return [] + patches = [ + { + "type": "application/json-patch+json", + "patch": [ + { + "op": "add", + "path": "/service/spec/ports/-", + "value": { + "name": "ssh", + "port": config.sessions.ssh.service_port, + "protocol": "TCP", + "targetPort": "ssh", + }, + }, + { + "op": "add", + "path": "/statefulset/spec/template/spec/containers/0/ports", + "value": [ + { + "name": "ssh", + "containerPort": config.sessions.ssh.container_port, + "protocol": "TCP", + }, + ], + }, + ], + } + ] + if config.sessions.ssh.host_key_secret: + patches.append( + { + "type": "application/json-patch+json", + "patch": [ + { + "op": "add", + "path": "/statefulset/spec/template/spec/containers/0/volumeMounts/-", + "value": { + "name": "ssh-host-keys", + "mountPath": config.sessions.ssh.host_key_location, + }, + }, + { + "op": "add", + "path": "/statefulset/spec/template/spec/volumes/-", + "value": { + "name": "ssh-host-keys", + "secret": { + "secretName": config.sessions.ssh.host_key_secret + }, + }, + }, + ], + } + ) + return patches diff --git a/renku_notebooks/api/classes/server.py b/renku_notebooks/api/classes/server.py index 5d44fdc9d..f7d272d7a 100644 --- a/renku_notebooks/api/classes/server.py +++ b/renku_notebooks/api/classes/server.py @@ -15,6 +15,7 @@ from ..amalthea_patches import init_containers as init_containers_patches from ..amalthea_patches import inject_certificates as inject_certificates_patches from ..amalthea_patches import jupyter_server as jupyter_server_patches +from ..amalthea_patches import ssh as ssh_patches from ...config import config from ...errors.programming import ConfigurationError from ...errors.user import MissingResourceError @@ -274,6 +275,7 @@ def _get_session_manifest(self): git_sidecar_patches.main(self), general_patches.oidc_unverified_email(self), cloudstorage_patches.main(self), + ssh_patches.main(), # init container for certs must come before all other init containers # so that it runs first before all other init containers init_containers_patches.certificates(), diff --git a/renku_notebooks/config/__init__.py b/renku_notebooks/config/__init__.py index b250596b7..29df1584d 100644 --- a/renku_notebooks/config/__init__.py +++ b/renku_notebooks/config/__init__.py @@ -158,6 +158,7 @@ def get_config(default_config: str) -> _NotebooksConfig: git-sidecar, ] } + ssh {} enforce_cpu_limits: false autosave_minimum_lfs_file_size_bytes: 1000000 termination_grace_period_seconds: 600 diff --git a/renku_notebooks/config/dynamic.py b/renku_notebooks/config/dynamic.py index 01f15694c..676a9d627 100644 --- a/renku_notebooks/config/dynamic.py +++ b/renku_notebooks/config/dynamic.py @@ -173,6 +173,20 @@ class _SessionContainers: registered: List[Text] +@dataclass +class _SessionSshConfig: + enabled: Union[Text, bool] = False + service_port: Union[Text, int] = 22 + container_port: Union[Text, int] = 2022 + host_key_secret: Optional[Text] = None + host_key_location: Text = "/opt/ssh/ssh_host_keys" + + def __post_init__(self): + self.enabled = _parse_str_as_bool(self.enabled) + self.service_port = _parse_value_as_numeric(self.service_port, int) + self.container_port = _parse_value_as_numeric(self.container_port, int) + + @dataclass class _SessionConfig: culling: _SessionCullingConfig @@ -184,6 +198,7 @@ class _SessionConfig: oidc: _SessionOidcConfig storage: _SessionStorageConfig containers: _SessionContainers + ssh: _SessionSshConfig default_image: Text = "renku/singleuser:latest" enforce_cpu_limits: Union[Text, bool] = False autosave_minimum_lfs_file_size_bytes: Union[int, Text] = 1000000 diff --git a/ssh-jump-host/Dockerfile b/ssh-jump-host/Dockerfile new file mode 100644 index 000000000..c54aa311d --- /dev/null +++ b/ssh-jump-host/Dockerfile @@ -0,0 +1,19 @@ +FROM alpine:3.17.1 + +RUN apk add --update --no-cache openssh-server tini && \ + mkdir -p /opt/ssh && \ + adduser -D jovyan && \ + passwd -d jovyan && \ + chown -R jovyan:users /opt/ssh && \ + addgroup jovyan shadow + +USER jovyan +RUN mkdir -p /opt/ssh/sshd_config.d && \ + mkdir -p /opt/ssh/ssh_host_keys && \ + ssh-keygen -q -N "" -t dsa -f /opt/ssh/ssh_host_keys/ssh_host_dsa_key && \ + ssh-keygen -q -N "" -t rsa -b 4096 -f /opt/ssh/ssh_host_keys/ssh_host_rsa_key && \ + ssh-keygen -q -N "" -t ecdsa -f /opt/ssh/ssh_host_keys/ssh_host_ecdsa_key && \ + ssh-keygen -q -N "" -t ed25519 -f /opt/ssh/ssh_host_keys/ssh_host_ed25519_key +COPY sshd_config /opt/ssh/sshd_config +ENTRYPOINT ["tini", "-g", "--"] +CMD [ "/usr/sbin/sshd", "-D", "-f", "/opt/ssh/sshd_config", "-e" ] diff --git a/ssh-jump-host/sshd_config b/ssh-jump-host/sshd_config new file mode 100644 index 000000000..bff173687 --- /dev/null +++ b/ssh-jump-host/sshd_config @@ -0,0 +1,23 @@ +Include /opt/ssh/sshd_config.d/*.conf +Port 2022 + +HostKey /opt/ssh/ssh_host_keys/ssh_host_dsa_key +HostKey /opt/ssh/ssh_host_keys/ssh_host_rsa_key +HostKey /opt/ssh/ssh_host_keys/ssh_host_ecdsa_key +HostKey /opt/ssh/ssh_host_keys/ssh_host_ed25519_key + +ChallengeResponseAuthentication no + +X11Forwarding no +PrintMotd no +PidFile /opt/ssh/sshd.pid + +AcceptEnv LANG LC_* + +Match User jovyan + PermitTTY no + X11Forwarding no + PermitTunnel no + GatewayPorts no + ForceCommand /sbin/nologin + PermitEmptyPasswords yes From 988bcaee5251995148e650f26cb7cef16d651782 Mon Sep 17 00:00:00 2001 From: Ralf Grubenmann Date: Thu, 26 Jan 2023 10:44:52 +0100 Subject: [PATCH 2/4] add ssh enabled to /version endpoint --- helm-chart/renku-notebooks/templates/statefulset.yaml | 2 ++ renku_notebooks/api/notebooks.py | 1 + renku_notebooks/config/__init__.py | 3 +++ renku_notebooks/config/dynamic.py | 2 ++ 4 files changed, 8 insertions(+) diff --git a/helm-chart/renku-notebooks/templates/statefulset.yaml b/helm-chart/renku-notebooks/templates/statefulset.yaml index 9622389bb..8c874fcdf 100644 --- a/helm-chart/renku-notebooks/templates/statefulset.yaml +++ b/helm-chart/renku-notebooks/templates/statefulset.yaml @@ -84,6 +84,8 @@ spec: value: "{{ .Values.gitClone.image.name }}:{{ .Values.gitClone.image.tag }}" - name: NB_ANONYMOUS_SESSIONS_ENABLED value: {{ .Values.global.anonymousSessions.enabled | quote }} + - name: NB_SSH_ENABLED + value: {{ .Values.ssh.enabled | quote }} - name: NB_SESSIONS__CULLING__REGISTERED__IDLE_SECONDS value: {{ .Values.culling.idleThresholdSeconds.registered | quote }} - name: NB_SESSIONS__CULLING__ANONYMOUS__IDLE_SECONDS diff --git a/renku_notebooks/api/notebooks.py b/renku_notebooks/api/notebooks.py index e918ff6cf..1219a2429 100644 --- a/renku_notebooks/api/notebooks.py +++ b/renku_notebooks/api/notebooks.py @@ -69,6 +69,7 @@ def version(): "s3": config.cloud_storage.s3.enabled, "azure_blob": config.cloud_storage.azure_blob.enabled, }, + "sshEnabled": config.ssh_enabled, }, } ], diff --git a/renku_notebooks/config/__init__.py b/renku_notebooks/config/__init__.py index 29df1584d..29c0c5460 100644 --- a/renku_notebooks/config/__init__.py +++ b/renku_notebooks/config/__init__.py @@ -28,6 +28,7 @@ class _NotebooksConfig: cloud_storage: _CloudStorage current_resource_schema_version: int = 1 anonymous_sessions_enabled: Union[Text, bool] = False + ssh_enabled: Union[Text, bool] = False service_prefix: str = "/notebooks" version: str = "0.0.0" keycloak_realm: str = "Renku" @@ -36,6 +37,7 @@ def __post_init__(self): self.anonymous_sessions_enabled = _parse_str_as_bool( self.anonymous_sessions_enabled ) + self.ssh_enabled = _parse_str_as_bool(self.ssh_enabled) self.session_get_endpoint_annotations = _ServersGetEndpointAnnotations() if not self.k8s.enabled: return @@ -197,6 +199,7 @@ def get_config(default_config: str) -> _NotebooksConfig: mount_folder = /cloudstorage } anonymous_sessions_enabled = false +ssh_enabled = false service_prefix = /notebooks version = 0.0.0 keycloak_realm = Renku diff --git a/renku_notebooks/config/dynamic.py b/renku_notebooks/config/dynamic.py index 676a9d627..d26a2f189 100644 --- a/renku_notebooks/config/dynamic.py +++ b/renku_notebooks/config/dynamic.py @@ -241,6 +241,7 @@ class _DynamicConfig: sentry: _SentryConfig git: _GitConfig anonymous_sessions_enabled: Union[Text, bool] = False + ssh_enabled: Union[Text, bool] = False service_prefix: str = "/notebooks" version: str = "0.0.0" @@ -248,6 +249,7 @@ def __post_init__(self): self.anonymous_sessions_enabled = _parse_str_as_bool( self.anonymous_sessions_enabled ) + self.ssh_enabled = _parse_str_as_bool(self.ssh_enabled) @dataclass From 1cba78dbcd2e55be03054cea6a6843ccd9dfdad2 Mon Sep 17 00:00:00 2001 From: Ralf Grubenmann Date: Fri, 27 Jan 2023 11:10:44 +0100 Subject: [PATCH 3/4] make jump host verbose --- ssh-jump-host/sshd_config | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ssh-jump-host/sshd_config b/ssh-jump-host/sshd_config index bff173687..7cdd7a219 100644 --- a/ssh-jump-host/sshd_config +++ b/ssh-jump-host/sshd_config @@ -12,6 +12,8 @@ X11Forwarding no PrintMotd no PidFile /opt/ssh/sshd.pid +LogLevel DEBUG3 + AcceptEnv LANG LC_* Match User jovyan From 77c129f38b04d59111941ff81296a25d402cc045 Mon Sep 17 00:00:00 2001 From: Ralf Grubenmann Date: Fri, 27 Jan 2023 15:05:44 +0100 Subject: [PATCH 4/4] add ingress/egress network policies --- .../templates/network-policy.yaml | 47 +++++++++++++++++++ ssh-jump-host/sshd_config | 2 - 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/helm-chart/renku-notebooks/templates/network-policy.yaml b/helm-chart/renku-notebooks/templates/network-policy.yaml index 83859c0b1..3c9d223ea 100644 --- a/helm-chart/renku-notebooks/templates/network-policy.yaml +++ b/helm-chart/renku-notebooks/templates/network-policy.yaml @@ -18,3 +18,50 @@ spec: ports: - protocol: TCP port: http +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ template "notebooks.fullname" . }}-ssh-sessions +spec: + podSelector: + matchLabels: + app.kubernetes.io/component: jupyterserver + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/name: amalthea + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app: {{ template "notebooks.name" . }}-ssh + ports: + - port: ssh + protocol: TCP +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ template "notebooks.fullname" . }}-ssh-sessions-egress +spec: + podSelector: + matchLabels: + app: {{ template "notebooks.name" . }}-ssh + policyTypes: + - Egress + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/component: jupyterserver + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/name: amalthea + ports: + - port: ssh + protocol: TCP + - ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 diff --git a/ssh-jump-host/sshd_config b/ssh-jump-host/sshd_config index 7cdd7a219..bff173687 100644 --- a/ssh-jump-host/sshd_config +++ b/ssh-jump-host/sshd_config @@ -12,8 +12,6 @@ X11Forwarding no PrintMotd no PidFile /opt/ssh/sshd.pid -LogLevel DEBUG3 - AcceptEnv LANG LC_* Match User jovyan