Since the dawn of the modern operating system, computer classes have used a shared login server to create a work environment for students. In the cloud era we've used virtual machines in place of physical servers (IaaS) to make our workloads more convenient to manage. But, our severs are still pets, they are precious to us, because they are stateful appliances running bespoke applications with some real problems:
- They are a single point of failure that impacts our ability to teach.
- Students are a security risk. I once caught a student who was able to maintain access after the end of a term.
- Management tools like VMware are clunky compared to cloud vendors.
- Cloud vendors are expensive for this kind of workload.
This project is a way to deploy a traditional login server into a Kubernetes cluster. I started working on it as a way to get a deeper understanding of cloud-native computing.
You should have a Kubernetes cluster already built and kubectl
and helm
installed and configured.
-
Add this Helm repo:
$ helm repo add cloud-native-server https://mike-matera.github.io/cloud-native-server/ $ helm repo update
-
Generate an SSH CA key:
$ ssh-keygen -t rsa -f ca_key -N ''
-
Sign your SSH public key using the CA key. This creates a certificate you can use to login to your server.
cp ~/.ssh/id_rsa.pub . ssh-keygen -s ./ca_key -I admin-key -n $USER ./id_rsa.pub
-
Use
helm
to deploy your server:$ helm install myserver cloud-native-server/cloud-server \ --set user=$USER \ --set hostName=myhost \ --set-file ssh.ca_key=./ca_key,ssh.ca_key_pub=./ca_key.pub
-
Wait for the application to deploy and check the service IP:
$ kubectl get service NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE test-ssh-cloud-server LoadBalancer 10.152.183.173 172.20.2.100 22:31456/TCP 5s
Login using the IP address:
$ ssh -o CertificateFile=./id_rsa-cert.pub $USER@172.20.2.100
-
Cleanup is a two step process because home directories are preserved between application installs and uninstalls to protect user's precious data.
-
Uninstall the Helm chart:
$ helm uninstall myserver
-
Delete home directories:
$ kubectl delete pvc home-myserver-cloud-server-0
-
The following table shows the configuration options available in cloud-server/values.yaml
. Most configuration tasks can be accomplished using these values:
Name | Description | Value |
---|---|---|
homeSize |
Size of the /home mount |
2Gi |
homeStorageClassName |
Storage class for the home mount | "" |
user |
The username of the default user | "human" |
userID |
The UID of the default user | 10000 |
hostName |
The hostname | "myserver" |
userSSHKey |
An SSH public key that will be added to authorized_keys of the default user. | "" |
userSSHImport |
Import default user keys with ssh-import-id |
"" |
customizeRepo |
A repository that will be checked out on boot. | "" |
customizeRepoCommand |
A command to run in the repository named in customizeRepoCommand . |
"" |
customizeCommand |
Run this command in the default user's home directory if it exists. | "" |
privileged |
Create a privileged container. See the "Security" section for details | true |
Name | Description | Value |
---|---|---|
ssh.ca_key |
An SSH private key for signing SSH certificates | None/Required |
ssh.ca_key_pub |
The corresponding public key for ssh.ca_key |
None/Required |
ssh.existingSecret |
An existing secret with keys ca_key and ca_key_pub . The other settings are ignored if this one exists. |
None/Required |
Name | Description | Value |
---|---|---|
image.repository |
The image repository for the container. | ghcr.io/mike-matera/cloud-native-server |
image.tag |
Container image tag. Change this for different distros. | jammy-2022070801 |
image.pullPolicy |
Image pull policy | IfNotPresent |
image.pullSecrets |
Specify docker-registry secret names as an array | [] |
You can add mounted volumes to your container. This is useful when you want to keep some persistent data separate from other persistent data. For example you might have a home directory structure that separates students depending on what class they're enrolled in.
extraMounts:
- name: cis90
path: /home/cis90
size: 1Gi
className: hdd
- name: cis91
path: /home/cis91
size: 1Gi
className: hdd
The extraMounts
array will create a PersistentVolumeClaim
for each entry. All of the fields are required (including the one for the storageClass name).
If you want your sever to be able to handle incoming connections on more than just port 22 for SSH you can specify them using the extraPorts
key in your custom.yaml
file. For example if you want to run a web server you could add:
extraPorts:
- port: 80
proto: TCP
name: http
- port: 443
proto: TCP
name: https
All of the fields are required (including proto
and name
which is an arbitrary identifier)
There are three stages where configuration can be inserted:
- Executing an arbitrary command in the default user's home directory. This is the most flexible stage.
- The execution of
/etc/rc.local
on boot. This stage customizes the image with SSH keys and creates the admin user. - The
Dockerfile
build. The purpose of this stage is have a functional base container with most of good stuff installed. This is the least flexible stage.
The next sections describe how to customize each stage.
The default /etc/rc.local
script uses variables that placed in the /etc/rc.env
file. The following customization variables become environment variables at startup:
Chart Variable | Environment Variable | Default |
---|---|---|
user |
DEFAULT_USER |
human |
userSSHKey |
DEFAULT_KEY |
|
userSSHImport |
DEFAULT_KEY_IMPORT |
|
hostName |
SET_HOSTNAME |
myserver |
customizeRepo |
CUSTOMIZE_REPO |
|
customizeRepoCommand |
CUSTOMIZE_REPO_COMMAND |
|
customizeCommand |
CUSTOMIZE_COMMAND |
Content added to the rcEnv
key in your custom.yaml
file will be appended to /etc/rc.env
. The environment variables in /etc/rc.env
are defined during system start and during user customization. This is a good place to put API keys and other secrets that might be useful during customization.
The /etc/rc.local
script does the following:
- Sets the hostname if possible.
- Creates the default user.
- Executes the
/etc/rc.user
script as the default user.
The /etc/rc.user
script does the following:
- Installs SSH keys into the user's account (see
userSSHKey
anduserSSHImport
) - Checks out
customizeRepo
into a temporary directory and runscustomizeRepoCommand
- Runs
customizeCommand
Errors in /etc/rc.user
are ignored. If they occur the container may not work but will become ready.
- This is the easiest way to customize your server. Try this first.
The images use systemd
to run the classic rc.local
script. This works well because there's existing infrastructure so not much image customization has to be done. The sshd
service has been updated to wait for the rc-local
service to finish, because SSH keys are generated by /etc/rc.local
. The contents of /etc/rc.local
and /etc/rc.user
are in the Helm chart. You can update the script by adding the following key to custom.yaml
:
rcLocal: |
#! /usr/bin/bash
set -e
. /etc/rc.env
[...your code here...]
You should look at the existing code in cloud-server/values.yaml
. There are a few important things that the rc.local
script must do:
- Set the hostname
- Create SSH host keys and sign them with the SSH CA key
- Create the admin user, make sure they have
sudo
access and install their SSH public keys - Any personal customization
- Touch the
/ready
file to tell kubernetes that the container is ready.
- If you have a non-Ansible way to deliver customization, i.e. Puppet or Chef networks.
You can find the Dockerfile
for the supported distros in the containers
directory. If you clone this repository on GitHub you can reuse the .github/workflows/*
files to build your own packages. Container builds are triggered by certain tags. For example, the Ubuntu container is build on a jammy-*
tag.
If you use a custom image, create a values file called custom.yaml
and add the following lines to it:
image:
repository: ghcr.io/your-github-name-here/cloud-native-server
tag: "your-tag-here"
Helm will then deploy your custom image.
- If you want a lot of new or different packages installed. You can install packages during the user customization stage.
- If you want to use Puppet or Chef instead of Ansible.
- If you want to use a non-supplied distro.
This project was inspired by running LXD containers for the last few years. They took the place of VMware VMs and I was really happy with how much easier they are to build and manage. I started to wonder, would it be possible to run systemd
in an unprivileged container in Kubernetes? It turns out the answer is no (I will explain this in detail at some point). This project runs systemd
in a privileged container, but also supports unprivileged containers by launching sshd
directly without systemd
.
Containerization is not a security technology. Running untrusted workloads --like a student shell-- carries a greater risk than running the same workload in a VM. But containers are lighter, more flexible and convenient, more robust and resilient than VMs. Until Kubernetes supports user namespaces a container escape from a privileged container means the attacker has full control of the node.
There is one essential question to consider when you deploy this project:
Will you give untrusted users
sudo
access?
The next sections will show you how to configure the application based on the answer to the question above.
Run the privileged version. This the default. You can set the privileged setting in your custom.yaml
file:
$ helm install .... --set privileged=true
This results in a very VM-like system. Users login and get a personal cgroup
and can see each other with the who
and w
commands. The system runs normally with periodic security updates, cron
and all of the things you'd expect. But, the container runs as root on the node so a container escape is serious. Container escapes are harder --maybe impossible-- as a non privileged user on your system.
Run the unprivileged version.
$ helm install .... --set privileged=false
This results in a system that's only running sshd
. The Kubernetes deployment overrides the container's entrypoint do to this. Many things will still work and the user has sudo
access. The container itself is running as an unprivileged user so a container escape is far less damaging.
Annoyingly, in order to set the hostname inside of the container the container process must have the CAP_SYS_ADMIN
capability (when specified in Kubernetes it's named SYS_ADMIN
). Privileged containers will obey the SET_HOSTNAME
variable and have a nice hostname:
[admin@opus /]#
Unprivileged containers will get the default hostname which comes from Kubernetes:
[admin@myserver-cloud-server-0 /]#
There's no fix for this.