0.1 commit
This commit is contained in:
parent
ba81b11579
commit
ab9607b7f1
8 changed files with 655 additions and 44 deletions
435
README.md
Normal file
435
README.md
Normal file
|
|
@ -0,0 +1,435 @@
|
||||||
|
# mentalnet-microcontainers
|
||||||
|
|
||||||
|
A DIY provisioning cloud for your homelab.
|
||||||
|
|
||||||
|
`mentalnet-microcontainers` gives you a tiny, self-hosted control plane for spinning up Docker containers and using them like lightweight VMs:
|
||||||
|
|
||||||
|
- Web UI to paste an SSH public key and pick a base image
|
||||||
|
- Automatically provisions a container with:
|
||||||
|
- User `micro` (with `sudo`)
|
||||||
|
- SSH key–only authentication
|
||||||
|
- Unique SSH port on the host
|
||||||
|
- Dedicated 3 GB ext4 filesystem mounted as `/home/micro`
|
||||||
|
- Designed for “I just want a throwaway dev box on my own hardware”, not full multi-tenant hosting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this actually does
|
||||||
|
|
||||||
|
When you hit the web UI and create a “microcontainer”, the app:
|
||||||
|
|
||||||
|
1. Validates your SSH public key.
|
||||||
|
2. Generates a unique container name:
|
||||||
|
- e.g. `mc-debian-myproject-8c5268`
|
||||||
|
3. Creates a tenant directory on the host:
|
||||||
|
- `/srv/microcontainers/<container_name>/`
|
||||||
|
4. Creates a sparse 3 GB `disk.img`:
|
||||||
|
- `fallocate -l 3G disk.img`
|
||||||
|
- `mkfs.ext4 disk.img`
|
||||||
|
5. Mounts it on the host:
|
||||||
|
- `/mnt/microcontainers/<container_name>/`
|
||||||
|
6. Writes your SSH key to:
|
||||||
|
- `/mnt/microcontainers/<container_name>/.ssh/authorized_keys`
|
||||||
|
7. Fixes ownership to match the in-container user:
|
||||||
|
- `chown -R 1000:1000 /mnt/microcontainers/<container_name>`
|
||||||
|
8. Picks a free port in a range (e.g. `20000–21000`).
|
||||||
|
9. Starts a Docker container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name <container_name> \
|
||||||
|
--memory=512m \
|
||||||
|
--memory-swap=512m \
|
||||||
|
-p HOST_PORT:22 \
|
||||||
|
-v /mnt/microcontainers/<container_name>:/home/micro \
|
||||||
|
micro-debian-dev:latest # or micro-fedora43-dev:latest
|
||||||
|
````
|
||||||
|
|
||||||
|
10. Shows you an SSH command like:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh micro@your-hostname -p 20013
|
||||||
|
```
|
||||||
|
|
||||||
|
From your perspective, it feels like “click → get a tiny VM with a 3 GB home and sudo.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
* **DIY provisioning cloud**
|
||||||
|
Turn one Docker host into a small pool of micro-VM-like environments.
|
||||||
|
|
||||||
|
* **Web UI (Flask)**
|
||||||
|
|
||||||
|
* Paste SSH key
|
||||||
|
* Choose base image (Debian / Fedora)
|
||||||
|
* Optional label to tag the container name
|
||||||
|
|
||||||
|
* **Per-tenant filesystem**
|
||||||
|
|
||||||
|
* Each microcontainer gets its own 3 GB ext4 filesystem
|
||||||
|
* Mounted as `/home/micro`
|
||||||
|
* Prevents a single tenant from filling the host via `$HOME`
|
||||||
|
|
||||||
|
* **SSH-only login**
|
||||||
|
|
||||||
|
* User: `micro`
|
||||||
|
* Auth: SSH public key only (no passwords)
|
||||||
|
* `sudo` enabled inside the container
|
||||||
|
|
||||||
|
* **Port range allocation**
|
||||||
|
|
||||||
|
* Host ports auto-assigned from a configured range (e.g. `20000–21000`)
|
||||||
|
* One container per SSH port
|
||||||
|
|
||||||
|
* **Image-agnostic**
|
||||||
|
|
||||||
|
* Comes with sample Debian & Fedora dev images
|
||||||
|
* Easy to add your own `micro-*-dev` images
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
Example layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mentalnet-microcontainers/
|
||||||
|
app.py
|
||||||
|
requirements.txt
|
||||||
|
docker/
|
||||||
|
micro-debian-dev/
|
||||||
|
Dockerfile
|
||||||
|
build-dockerfile.sh
|
||||||
|
micro-fedora43-dev/
|
||||||
|
Dockerfile
|
||||||
|
build-dockerfile.sh
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
You can add more images under `docker/` later (e.g. `micro-alpine-dev`, `micro-arch-dev`, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base images
|
||||||
|
|
||||||
|
### Build script convention
|
||||||
|
|
||||||
|
Each image directory contains a simple build script:
|
||||||
|
|
||||||
|
`docker/micro-debian-dev/build-dockerfile.sh` (same pattern for Fedora):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE_NAME="$(basename "$(pwd)")"
|
||||||
|
|
||||||
|
echo "Building Docker image: ${IMAGE_NAME}:latest"
|
||||||
|
docker build -t "${IMAGE_NAME}:latest" .
|
||||||
|
```
|
||||||
|
|
||||||
|
Name your directory the same as the image you want (`micro-debian-dev`, `micro-fedora43-dev`, etc.) and the script will build `IMAGE_NAME:latest` automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Debian dev box (`micro-debian-dev`)
|
||||||
|
|
||||||
|
`docker/micro-debian-dev/Dockerfile`:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM debian:stable-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
openssh-server \
|
||||||
|
sudo \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
curl wget \
|
||||||
|
vim nano \
|
||||||
|
htop \
|
||||||
|
build-essential && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create 'micro' user with fixed uid/gid 1000
|
||||||
|
RUN useradd -m -u 1000 -U -s /bin/bash micro && \
|
||||||
|
echo "micro:ChangeMe123" | chpasswd && \
|
||||||
|
usermod -aG sudo micro
|
||||||
|
|
||||||
|
# Prepare .ssh directory (runtime volume will mount over /home/micro)
|
||||||
|
RUN mkdir -p /home/micro/.ssh && \
|
||||||
|
chown -R micro:micro /home/micro && \
|
||||||
|
chmod 700 /home/micro/.ssh
|
||||||
|
|
||||||
|
# SSH server config: key-only login, use ~/.ssh/authorized_keys
|
||||||
|
RUN sed -i 's/^#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config || true && \
|
||||||
|
sed -i 's/^PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config || true && \
|
||||||
|
sed -i 's/^#KbdInteractiveAuthentication yes/KbdInteractiveAuthentication no/' /etc/ssh/sshd_config || true && \
|
||||||
|
sed -i 's/^#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config || true && \
|
||||||
|
sed -i 's|^#AuthorizedKeysFile.*|AuthorizedKeysFile .ssh/authorized_keys|' /etc/ssh/sshd_config || true && \
|
||||||
|
echo 'UsePAM no' >> /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
RUN mkdir -p /var/run/sshd && \
|
||||||
|
ssh-keygen -A
|
||||||
|
|
||||||
|
EXPOSE 22
|
||||||
|
|
||||||
|
CMD ["/usr/sbin/sshd", "-D"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker/micro-debian-dev
|
||||||
|
./build-dockerfile.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fedora 43 dev box (`micro-fedora43-dev`)
|
||||||
|
|
||||||
|
`docker/micro-fedora43-dev/Dockerfile`:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM fedora:43
|
||||||
|
|
||||||
|
RUN dnf -y update && \
|
||||||
|
dnf -y install \
|
||||||
|
openssh-server \
|
||||||
|
sudo \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
curl wget \
|
||||||
|
vim nano \
|
||||||
|
htop \
|
||||||
|
gcc gcc-c++ make && \
|
||||||
|
dnf clean all && \
|
||||||
|
rm -rf /var/cache/dnf
|
||||||
|
|
||||||
|
RUN useradd -m -u 1000 -U -s /bin/bash micro && \
|
||||||
|
echo "micro:ChangeMe123" | chpasswd && \
|
||||||
|
usermod -aG wheel micro
|
||||||
|
|
||||||
|
RUN mkdir -p /home/micro/.ssh && \
|
||||||
|
chown -R micro:micro /home/micro && \
|
||||||
|
chmod 700 /home/micro/.ssh
|
||||||
|
|
||||||
|
RUN sed -i 's/^#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config || true && \
|
||||||
|
sed -i 's/^PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config || true && \
|
||||||
|
sed -i 's/^#KbdInteractiveAuthentication yes/KbdInteractiveAuthentication no/' /etc/ssh/sshd_config || true && \
|
||||||
|
sed -i 's/^#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config || true && \
|
||||||
|
sed -i 's|^#AuthorizedKeysFile.*|AuthorizedKeysFile .ssh/authorized_keys|' /etc/ssh/sshd_config || true && \
|
||||||
|
echo 'UsePAM no' >> /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
RUN mkdir -p /var/run/sshd && \
|
||||||
|
ssh-keygen -A
|
||||||
|
|
||||||
|
EXPOSE 22
|
||||||
|
|
||||||
|
CMD ["/usr/sbin/sshd", "-D"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker/micro-fedora43-dev
|
||||||
|
./build-dockerfile.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Flask app (`app.py`)
|
||||||
|
|
||||||
|
Core ideas in `app.py`:
|
||||||
|
|
||||||
|
* Configuration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
IMAGES = {
|
||||||
|
"debian": {
|
||||||
|
"docker_image": "micro-debian-dev:latest",
|
||||||
|
"label": "Debian Dev Box (micro-debian-dev)"
|
||||||
|
},
|
||||||
|
"fedora": {
|
||||||
|
"docker_image": "micro-fedora43-dev:latest",
|
||||||
|
"label": "Fedora 43 Dev Box (micro-fedora43-dev)"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_IMAGE_KEY = "debian"
|
||||||
|
|
||||||
|
DATA_ROOT = "/srv/microcontainers" # per-tenant metadata + disk.img
|
||||||
|
MOUNT_ROOT = "/mnt/microcontainers" # where disk.img files are mounted
|
||||||
|
HOSTNAME = "arthur.lan" # only used for display in UI
|
||||||
|
PORT_RANGE_START = 20000
|
||||||
|
PORT_RANGE_END = 21000
|
||||||
|
```
|
||||||
|
|
||||||
|
* Each POST to `/`:
|
||||||
|
|
||||||
|
* Validates the SSH key
|
||||||
|
* Picks an image
|
||||||
|
* Generates a container name
|
||||||
|
* Calls `ensure_tenant_disk()` to create/mount the 3 GB ext4 filesystem and write `authorized_keys`
|
||||||
|
* Chooses a free port in the range
|
||||||
|
* Calls `run_docker_container()` with:
|
||||||
|
|
||||||
|
* `-p HOST_PORT:22`
|
||||||
|
* `-v home_mount:/home/micro`
|
||||||
|
|
||||||
|
Returned info includes container name, host port, and an SSH command.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running the app
|
||||||
|
|
||||||
|
### 1. Install dependencies
|
||||||
|
|
||||||
|
Create a venv (optional but recommended):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
`requirements.txt`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Flask>=3.0,<4.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run the Flask server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
By default it listens on `0.0.0.0:5000`.
|
||||||
|
|
||||||
|
You may also want a firewall rule to allow your LAN:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example with iptables (adjust to your LAN subnet)
|
||||||
|
iptables -A INPUT -s 192.168.86.0/24 -p tcp --dport 5000 -m state --state NEW -j ACCEPT
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use the UI
|
||||||
|
|
||||||
|
From a machine on your LAN:
|
||||||
|
|
||||||
|
1. Open `http://HOST:5000/` (replace `HOST` with your VM’s IP/hostname).
|
||||||
|
2. Paste your SSH public key (one line, e.g. `ssh-ed25519 AAAA... user@host`).
|
||||||
|
3. Choose an image (`debian` or `fedora`).
|
||||||
|
4. Optionally add a label (e.g. `project-x-dev`).
|
||||||
|
5. Click **Provision MicroContainer**.
|
||||||
|
|
||||||
|
If successful you’ll see something like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Container created!
|
||||||
|
Image: debian — Debian Dev Box (micro-debian-dev)
|
||||||
|
Container name: mc-debian-project-x-8c5268
|
||||||
|
Host port: 20013
|
||||||
|
SSH command:
|
||||||
|
ssh micro@arthur.lan -p 20013
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
You can tune basic behavior at the top of `app.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
DATA_ROOT = "/srv/microcontainers"
|
||||||
|
MOUNT_ROOT = "/mnt/microcontainers"
|
||||||
|
HOSTNAME = "arthur.lan" # used only for the SSH hint in the UI
|
||||||
|
PORT_RANGE_START = 20000
|
||||||
|
PORT_RANGE_END = 21000
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
* On a VPS, you might set:
|
||||||
|
|
||||||
|
```python
|
||||||
|
HOSTNAME = "your.vps.domain"
|
||||||
|
```
|
||||||
|
|
||||||
|
* To change ports to e.g. `30000–30100`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PORT_RANGE_START = 30000
|
||||||
|
PORT_RANGE_END = 30100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
This is a **homelab prototype**, not hardened multi-tenant hosting.
|
||||||
|
|
||||||
|
Things to be aware of:
|
||||||
|
|
||||||
|
* **No auth on the UI**
|
||||||
|
Anyone who can reach port 5000 can create containers.
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
* Bind to `127.0.0.1` and reverse tunnel
|
||||||
|
* Or put it behind a VPN / reverse proxy with auth
|
||||||
|
|
||||||
|
* **Runs as root**
|
||||||
|
The app calls `fallocate`, `mkfs.ext4`, `mount`, `chown`, and `docker run`.
|
||||||
|
It’s intended to run as root (or equivalent) on a box you control.
|
||||||
|
|
||||||
|
* **SSH ports exposed on the host**
|
||||||
|
Example: `20000–21000`.
|
||||||
|
Use your firewall / router to decide who can reach them.
|
||||||
|
|
||||||
|
* **No automatic cleanup yet**
|
||||||
|
Stopping/removing containers and unmounting disks is manual for now:
|
||||||
|
|
||||||
|
* `docker ps`, `docker stop`, `docker rm`
|
||||||
|
* `umount /mnt/microcontainers/<container_name>`
|
||||||
|
* Optionally `rm -rf /srv/microcontainers/<container_name>/`
|
||||||
|
|
||||||
|
Use this like a DIY “micro-VPS factory” in your lab, not a fully managed public cloud.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Secret key
|
||||||
|
|
||||||
|
Flask uses `app.secret_key` to sign session data and flash messages.
|
||||||
|
|
||||||
|
In `app.py` this is read from the `MICROCONTAINERS_SECRET_KEY` environment variable:
|
||||||
|
|
||||||
|
```python
|
||||||
|
app.secret_key = os.environ.get("MICROCONTAINERS_SECRET_KEY", "dev-only-not-secret")
|
||||||
|
````
|
||||||
|
|
||||||
|
For anything beyond local testing, set a real random secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MICROCONTAINERS_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ideas / future work
|
||||||
|
|
||||||
|
* Add a simple **cleanup UI** for stopping/removing containers and unmounting disks.
|
||||||
|
* Optional **basic auth** or token-protected UI.
|
||||||
|
* More **SKUs**:
|
||||||
|
|
||||||
|
* `micro-alpine-dev`, `micro-arch-dev`, `micro-nix-dev`, etc.
|
||||||
|
* Stats page showing:
|
||||||
|
|
||||||
|
* Used vs total space per tenant
|
||||||
|
* Running containers and their ports
|
||||||
|
|
||||||
|
|
||||||
173
app.py
173
app.py
|
|
@ -7,17 +7,31 @@ import uuid
|
||||||
from flask import Flask, request, render_template_string, flash
|
from flask import Flask, request, render_template_string, flash
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = "change-me-in-production"
|
app.secret_key = os.environ.get("MICROCONTAINERS_SECRET_KEY", "dev-only-not-secret")
|
||||||
|
|
||||||
# --- Config -----------------------------------------------------------------
|
# --- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
DOCKER_IMAGE = "micro-debian-dev:latest" # built from the Dockerfile above
|
IMAGES = {
|
||||||
DATA_ROOT = "/srv/microcontainers" # where per-tenant dirs live
|
"debian": {
|
||||||
|
"docker_image": "micro-debian-dev:latest",
|
||||||
|
"label": "Debian Dev Box (micro-debian-dev)"
|
||||||
|
},
|
||||||
|
"fedora": {
|
||||||
|
"docker_image": "micro-fedora43-dev:latest",
|
||||||
|
"label": "Fedora 43 Dev Box (micro-fedora43-dev)"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_IMAGE_KEY = "debian"
|
||||||
|
|
||||||
|
DATA_ROOT = "/srv/microcontainers" # per-tenant metadata + disk.img
|
||||||
|
MOUNT_ROOT = "/mnt/microcontainers" # where disk.img files are mounted
|
||||||
HOSTNAME = "arthur.lan" # or "mentalnet.xyz" if you prefer
|
HOSTNAME = "arthur.lan" # or "mentalnet.xyz" if you prefer
|
||||||
PORT_RANGE_START = 20000
|
PORT_RANGE_START = 20000
|
||||||
PORT_RANGE_END = 21000
|
PORT_RANGE_END = 21000
|
||||||
|
|
||||||
os.makedirs(DATA_ROOT, exist_ok=True)
|
os.makedirs(DATA_ROOT, exist_ok=True)
|
||||||
|
os.makedirs(MOUNT_ROOT, exist_ok=True)
|
||||||
|
|
||||||
# --- Helpers ----------------------------------------------------------------
|
# --- Helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -49,21 +63,75 @@ def validate_ssh_key(key: str) -> bool:
|
||||||
return any(key.startswith(p) for p in allowed_prefixes)
|
return any(key.startswith(p) for p in allowed_prefixes)
|
||||||
|
|
||||||
|
|
||||||
def run_docker_container(container_name: str, ssh_dir: str, host_port: int):
|
def is_mounted(mount_point: str) -> bool:
|
||||||
|
"""Check if a given mount point is already mounted."""
|
||||||
|
try:
|
||||||
|
with open("/proc/mounts", "r") as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2 and parts[1] == mount_point:
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tenant_disk(container_name: str, ssh_key: str, size_gb: int = 3) -> str:
|
||||||
|
"""
|
||||||
|
Ensure a per-tenant disk.img exists, is formatted, mounted, and has
|
||||||
|
.ssh/authorized_keys populated.
|
||||||
|
|
||||||
|
Returns the host mount point path, which will be bind-mounted to /home/micro.
|
||||||
|
"""
|
||||||
|
tenant_root = os.path.join(DATA_ROOT, container_name)
|
||||||
|
os.makedirs(tenant_root, exist_ok=True)
|
||||||
|
|
||||||
|
disk_img = os.path.join(tenant_root, "disk.img")
|
||||||
|
mount_point = os.path.join(MOUNT_ROOT, container_name)
|
||||||
|
os.makedirs(mount_point, exist_ok=True)
|
||||||
|
|
||||||
|
# 1) Create sparse disk.img if it doesn't exist
|
||||||
|
if not os.path.exists(disk_img):
|
||||||
|
subprocess.run(["fallocate", "-l", f"{size_gb}G", disk_img], check=True)
|
||||||
|
subprocess.run(["mkfs.ext4", "-F", disk_img], check=True)
|
||||||
|
|
||||||
|
# 2) Mount it if not already mounted
|
||||||
|
if not is_mounted(mount_point):
|
||||||
|
subprocess.run(["mount", "-o", "loop", disk_img, mount_point], check=True)
|
||||||
|
|
||||||
|
# 3) Prepare /home/micro layout inside the filesystem
|
||||||
|
# (we're mounting this whole thing as /home/micro in the container)
|
||||||
|
ssh_dir = os.path.join(mount_point, ".ssh")
|
||||||
|
os.makedirs(ssh_dir, exist_ok=True)
|
||||||
|
|
||||||
|
auth_keys_path = os.path.join(ssh_dir, "authorized_keys")
|
||||||
|
with open(auth_keys_path, "w") as f:
|
||||||
|
f.write(ssh_key.strip() + "\n")
|
||||||
|
|
||||||
|
# Proper perms for SSH
|
||||||
|
os.chmod(ssh_dir, 0o700)
|
||||||
|
os.chmod(auth_keys_path, 0o600)
|
||||||
|
|
||||||
|
# Make everything in /home/micro look like micro:micro (uid/gid 1000)
|
||||||
|
subprocess.run(["chown", "-R", "1000:1000", mount_point], check=True)
|
||||||
|
|
||||||
|
return mount_point
|
||||||
|
|
||||||
|
|
||||||
|
def run_docker_container(container_name: str, host_port: int, docker_image: str, home_mount: str):
|
||||||
"""
|
"""
|
||||||
Start the tenant container:
|
Start the tenant container:
|
||||||
- Binds host_port -> container:22
|
- Binds host_port -> container:22
|
||||||
- Mounts authorized_keys -> /home/micro/.ssh/authorized_keys (read-only)
|
- Mounts per-tenant 3GB disk as /home/micro
|
||||||
"""
|
"""
|
||||||
auth_keys_path = os.path.join(ssh_dir, "authorized_keys")
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"docker", "run", "-d",
|
"docker", "run", "-d",
|
||||||
"--name", container_name,
|
"--name", container_name,
|
||||||
"--memory=512m",
|
"--memory=512m",
|
||||||
"--memory-swap=512m",
|
"--memory-swap=512m",
|
||||||
"-p", f"{host_port}:22",
|
"-p", f"{host_port}:22",
|
||||||
"-v", f"{auth_keys_path}:/home/micro/.ssh/authorized_keys:ro",
|
"-v", f"{home_mount}:/home/micro",
|
||||||
DOCKER_IMAGE,
|
docker_image,
|
||||||
]
|
]
|
||||||
subprocess.run(cmd, check=True)
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
|
@ -94,7 +162,7 @@ INDEX_TEMPLATE = """
|
||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
}
|
}
|
||||||
label { display: block; margin-bottom: 8px; font-weight: 600; }
|
label { display: block; margin-bottom: 8px; font-weight: 600; }
|
||||||
textarea, input[type=text] {
|
textarea, input[type=text], select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: #101010;
|
background: #101010;
|
||||||
|
|
@ -127,8 +195,14 @@ INDEX_TEMPLATE = """
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<p>This prototype spins up a small Docker-based "micro VM" and wires in your SSH public key.</p>
|
<p>This prototype spins up a small Docker-based "micro VM" and wires in your SSH public key.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Base image: <code>{{ docker_image }}</code></li>
|
<li>Available base images:</li>
|
||||||
|
<ul>
|
||||||
|
{% for key, info in images.items() %}
|
||||||
|
<li><code>{{ key }}</code>: {{ info.label }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
<li>User: <code>micro</code></li>
|
<li>User: <code>micro</code></li>
|
||||||
|
<li>Per-tenant /home size: <code>3 GB</code> (loopback ext4)</li>
|
||||||
<li>SSH inside container: <code>port 22</code></li>
|
<li>SSH inside container: <code>port 22</code></li>
|
||||||
<li>Host port: auto-assigned from <code>{{ port_start }}-{{ port_end }}</code></li>
|
<li>Host port: auto-assigned from <code>{{ port_start }}-{{ port_end }}</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -147,6 +221,7 @@ INDEX_TEMPLATE = """
|
||||||
{% if result %}
|
{% if result %}
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h2 class="success">Container created!</h2>
|
<h2 class="success">Container created!</h2>
|
||||||
|
<p><strong>Image:</strong> <code>{{ result.image_key }}</code> — {{ images[result.image_key].label }}</p>
|
||||||
<p><strong>Container name:</strong> <code>{{ result.name }}</code></p>
|
<p><strong>Container name:</strong> <code>{{ result.name }}</code></p>
|
||||||
<p><strong>Host port:</strong> <code>{{ result.port }}</code></p>
|
<p><strong>Host port:</strong> <code>{{ result.port }}</code></p>
|
||||||
<p><strong>SSH command:</strong></p>
|
<p><strong>SSH command:</strong></p>
|
||||||
|
|
@ -158,6 +233,15 @@ INDEX_TEMPLATE = """
|
||||||
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<label for="image">Base image</label>
|
||||||
|
<select id="image" name="image">
|
||||||
|
{% for key, info in images.items() %}
|
||||||
|
<option value="{{ key }}" {% if key == selected_image %}selected{% endif %}>
|
||||||
|
{{ key }} - {{ info.label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
<label for="ssh_key">SSH public key</label>
|
<label for="ssh_key">SSH public key</label>
|
||||||
<textarea id="ssh_key" name="ssh_key" rows="6" placeholder="ssh-ed25519 AAAAC3Nz... user@host" required>{{ ssh_key or "" }}</textarea>
|
<textarea id="ssh_key" name="ssh_key" rows="6" placeholder="ssh-ed25519 AAAAC3Nz... user@host" required>{{ ssh_key or "" }}</textarea>
|
||||||
|
|
||||||
|
|
@ -180,87 +264,96 @@ def index():
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
return render_template_string(
|
return render_template_string(
|
||||||
INDEX_TEMPLATE,
|
INDEX_TEMPLATE,
|
||||||
docker_image=DOCKER_IMAGE,
|
images=IMAGES,
|
||||||
port_start=PORT_RANGE_START,
|
port_start=PORT_RANGE_START,
|
||||||
port_end=PORT_RANGE_END,
|
port_end=PORT_RANGE_END,
|
||||||
result=None,
|
result=None,
|
||||||
ssh_key="",
|
ssh_key="",
|
||||||
hostname=HOSTNAME,
|
hostname=HOSTNAME,
|
||||||
|
selected_image=DEFAULT_IMAGE_KEY,
|
||||||
)
|
)
|
||||||
|
|
||||||
# POST: handle provisioning
|
# POST: handle provisioning
|
||||||
ssh_key = (request.form.get("ssh_key") or "").strip()
|
ssh_key = (request.form.get("ssh_key") or "").strip()
|
||||||
note = (request.form.get("note") or "").strip()
|
note = (request.form.get("note") or "").strip()
|
||||||
|
image_key = (request.form.get("image") or DEFAULT_IMAGE_KEY).strip()
|
||||||
|
|
||||||
|
if image_key not in IMAGES:
|
||||||
|
image_key = DEFAULT_IMAGE_KEY
|
||||||
|
|
||||||
|
docker_image = IMAGES[image_key]["docker_image"]
|
||||||
|
|
||||||
if not validate_ssh_key(ssh_key):
|
if not validate_ssh_key(ssh_key):
|
||||||
flash("Invalid SSH public key format. Please paste a standard OpenSSH public key line.")
|
flash("Invalid SSH public key format. Please paste a standard OpenSSH public key line.")
|
||||||
return render_template_string(
|
return render_template_string(
|
||||||
INDEX_TEMPLATE,
|
INDEX_TEMPLATE,
|
||||||
docker_image=DOCKER_IMAGE,
|
images=IMAGES,
|
||||||
port_start=PORT_RANGE_START,
|
port_start=PORT_RANGE_START,
|
||||||
port_end=PORT_RANGE_END,
|
port_end=PORT_RANGE_END,
|
||||||
result=None,
|
result=None,
|
||||||
ssh_key=ssh_key,
|
ssh_key=ssh_key,
|
||||||
hostname=HOSTNAME,
|
hostname=HOSTNAME,
|
||||||
|
selected_image=image_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate a container name
|
# Generate a container name
|
||||||
suffix = uuid.uuid4().hex[:6]
|
suffix = uuid.uuid4().hex[:6]
|
||||||
base_name = "mc"
|
base_name = f"mc-{image_key}"
|
||||||
if note:
|
if note:
|
||||||
safe_note = "".join(c for c in note.lower().replace(" ", "-") if c.isalnum() or c in "-_")
|
safe_note = "".join(c for c in note.lower().replace(" ", "-") if c.isalnum() or c in "-_")
|
||||||
base_name = f"mc-{safe_note}"
|
base_name = f"mc-{image_key}-{safe_note}"
|
||||||
container_name = f"{base_name}-{suffix}"
|
container_name = f"{base_name}-{suffix}"
|
||||||
|
|
||||||
# Tenant SSH dir on host
|
# Ensure per-tenant disk (3 GB ext4) and authorized_keys
|
||||||
tenant_root = os.path.join(DATA_ROOT, container_name)
|
|
||||||
tenant_ssh_dir = os.path.join(tenant_root, "ssh")
|
|
||||||
os.makedirs(tenant_ssh_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# Write authorized_keys
|
|
||||||
auth_keys_path = os.path.join(tenant_ssh_dir, "authorized_keys")
|
|
||||||
with open(auth_keys_path, "w") as f:
|
|
||||||
f.write(ssh_key.strip() + "\n")
|
|
||||||
|
|
||||||
# Make it look like micro:micro (uid/gid 1000) to sshd in the container
|
|
||||||
os.chmod(auth_keys_path, 0o600)
|
|
||||||
try:
|
try:
|
||||||
os.chown(auth_keys_path, 1000, 1000) # micro:micro inside container
|
home_mount = ensure_tenant_disk(container_name, ssh_key, size_gb=3)
|
||||||
except PermissionError:
|
|
||||||
# If Flask isn't running as root, this will fail; on your VM it should succeed.
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Choose a free port and start container
|
|
||||||
host_port = find_free_port()
|
|
||||||
try:
|
|
||||||
run_docker_container(container_name, tenant_ssh_dir, host_port)
|
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
flash(f"Error starting container: {e}")
|
flash(f"Error preparing tenant disk: {e}")
|
||||||
return render_template_string(
|
return render_template_string(
|
||||||
INDEX_TEMPLATE,
|
INDEX_TEMPLATE,
|
||||||
docker_image=DOCKER_IMAGE,
|
images=IMAGES,
|
||||||
port_start=PORT_RANGE_START,
|
port_start=PORT_RANGE_START,
|
||||||
port_end=PORT_RANGE_END,
|
port_end=PORT_RANGE_END,
|
||||||
result=None,
|
result=None,
|
||||||
ssh_key=ssh_key,
|
ssh_key=ssh_key,
|
||||||
hostname=HOSTNAME,
|
hostname=HOSTNAME,
|
||||||
|
selected_image=image_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Choose a free port and start container
|
||||||
|
host_port = find_free_port()
|
||||||
|
try:
|
||||||
|
run_docker_container(container_name, host_port, docker_image, home_mount)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
flash(f"Error starting container: {e}")
|
||||||
|
return render_template_string(
|
||||||
|
INDEX_TEMPLATE,
|
||||||
|
images=IMAGES,
|
||||||
|
port_start=PORT_RANGE_START,
|
||||||
|
port_end=PORT_RANGE_END,
|
||||||
|
result=None,
|
||||||
|
ssh_key=ssh_key,
|
||||||
|
hostname=HOSTNAME,
|
||||||
|
selected_image=image_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Result:
|
class Result:
|
||||||
def __init__(self, name, port):
|
def __init__(self, name, port, image_key):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.port = port
|
self.port = port
|
||||||
|
self.image_key = image_key
|
||||||
|
|
||||||
result = Result(container_name, host_port)
|
result = Result(container_name, host_port, image_key)
|
||||||
|
|
||||||
return render_template_string(
|
return render_template_string(
|
||||||
INDEX_TEMPLATE,
|
INDEX_TEMPLATE,
|
||||||
docker_image=DOCKER_IMAGE,
|
images=IMAGES,
|
||||||
port_start=PORT_RANGE_START,
|
port_start=PORT_RANGE_START,
|
||||||
port_end=PORT_RANGE_END,
|
port_end=PORT_RANGE_END,
|
||||||
result=result,
|
result=result,
|
||||||
ssh_key="",
|
ssh_key="",
|
||||||
hostname=HOSTNAME,
|
hostname=HOSTNAME,
|
||||||
|
selected_image=image_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ RUN mkdir -p /var/run/sshd && \
|
||||||
|
|
||||||
# Fastfetch config for micro (optional flair)
|
# Fastfetch config for micro (optional flair)
|
||||||
RUN mkdir -p /home/micro/.config/fastfetch
|
RUN mkdir -p /home/micro/.config/fastfetch
|
||||||
COPY fastfetch_config.json /home/micro/.config/fastfetch/config.json
|
COPY fastfetch_config.json /home/micro/.config/fastfetch/config.jsonc
|
||||||
RUN chown -R micro:micro /home/micro/.config && \
|
RUN chown -R micro:micro /home/micro/.config && \
|
||||||
echo 'if command -v fastfetch >/dev/null 2>&1; then fastfetch; fi' >> /home/micro/.bashrc && \
|
echo 'if command -v fastfetch >/dev/null 2>&1; then fastfetch; fi' >> /home/micro/.bashrc && \
|
||||||
chown micro:micro /home/micro/.bashrc
|
chown micro:micro /home/micro/.bashrc
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
#echo $(basename $(pwd))
|
set -euo pipefail
|
||||||
docker build -t $(basename $(pwd)):latest .
|
|
||||||
|
IMAGE_NAME="$(basename "$(pwd)")"
|
||||||
|
|
||||||
|
echo "Building Docker image: ${IMAGE_NAME}:latest"
|
||||||
|
docker build -t "${IMAGE_NAME}:latest" .
|
||||||
|
|
||||||
|
|
|
||||||
51
micro-fedora43-dev/Dockerfile
Normal file
51
micro-fedora43-dev/Dockerfile
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
FROM fedora:43
|
||||||
|
|
||||||
|
# Basic tools + SSH + dev stack (+ fastfetch if available)
|
||||||
|
RUN dnf -y update && \
|
||||||
|
dnf -y install \
|
||||||
|
openssh-server \
|
||||||
|
sudo \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
curl wget \
|
||||||
|
vim nano \
|
||||||
|
htop \
|
||||||
|
gcc gcc-c++ make \
|
||||||
|
fastfetch || true && \
|
||||||
|
dnf clean all && \
|
||||||
|
rm -rf /var/cache/dnf
|
||||||
|
|
||||||
|
# Create 'micro' user with fixed uid/gid 1000
|
||||||
|
RUN useradd -m -u 1000 -U -s /bin/bash micro && \
|
||||||
|
echo "micro:ChangeMe123" | chpasswd && \
|
||||||
|
usermod -aG wheel micro
|
||||||
|
|
||||||
|
# Prepare .ssh directory
|
||||||
|
RUN mkdir -p /home/micro/.ssh && \
|
||||||
|
chown -R micro:micro /home/micro && \
|
||||||
|
chmod 700 /home/micro/.ssh
|
||||||
|
|
||||||
|
# SSH server config: key-only login, use ~/.ssh/authorized_keys
|
||||||
|
RUN sed -i 's/^#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config || true && \
|
||||||
|
sed -i 's/^PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config || true && \
|
||||||
|
sed -i 's/^#KbdInteractiveAuthentication yes/KbdInteractiveAuthentication no/' /etc/ssh/sshd_config || true && \
|
||||||
|
sed -i 's/^#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config || true && \
|
||||||
|
sed -i 's|^#AuthorizedKeysFile.*|AuthorizedKeysFile .ssh/authorized_keys|' /etc/ssh/sshd_config || true && \
|
||||||
|
echo 'UsePAM no' >> /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Generate host keys and make sure run dir exists
|
||||||
|
RUN mkdir -p /var/run/sshd && \
|
||||||
|
ssh-keygen -A
|
||||||
|
|
||||||
|
# Fastfetch config for micro (reuse your existing JSON)
|
||||||
|
RUN mkdir -p /home/micro/.config/fastfetch
|
||||||
|
COPY fastfetch_config.json /home/micro/.config/fastfetch/config.jsonc
|
||||||
|
RUN chown -R micro:micro /home/micro/.config && \
|
||||||
|
echo 'if command -v fastfetch >/dev/null 2>&1; then fastfetch; fi' >> /home/micro/.bashrc && \
|
||||||
|
echo 'alias fastfetch="fastfetch --config $HOME/.config/fastfetch/config.jsonc"' >> /home/micro/.bashrc && \
|
||||||
|
chown micro:micro /home/micro/.bashrc
|
||||||
|
|
||||||
|
EXPOSE 22
|
||||||
|
|
||||||
|
CMD ["/usr/sbin/sshd", "-D"]
|
||||||
|
|
||||||
8
micro-fedora43-dev/build-dockerfile.sh
Executable file
8
micro-fedora43-dev/build-dockerfile.sh
Executable file
|
|
@ -0,0 +1,8 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE_NAME="$(basename "$(pwd)")"
|
||||||
|
|
||||||
|
echo "Building Docker image: ${IMAGE_NAME}:latest"
|
||||||
|
docker build -t "${IMAGE_NAME}:latest" .
|
||||||
|
|
||||||
15
micro-fedora43-dev/fastfetch_config.json
Normal file
15
micro-fedora43-dev/fastfetch_config.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"display": {
|
||||||
|
"separator": " == "
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
"title",
|
||||||
|
"os",
|
||||||
|
"kernel",
|
||||||
|
"cpu",
|
||||||
|
"memory",
|
||||||
|
"disk",
|
||||||
|
"shell"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
4
start-server.sh
Executable file
4
start-server.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
export MICROCONTAINERS_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
|
||||||
|
python3 app.py
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue