feature/ssh-server #1

Merged
marcio.fernandes merged 21 commits from feature/ssh-server into main 2025-09-07 13:50:19 +00:00
20 changed files with 643 additions and 4 deletions
Showing only changes of commit f6e6d4dba9 - Show all commits

View File

@@ -22,11 +22,16 @@ jobs:
username: ${{ secrets.GITLIMBO_DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.GITLIMBO_DOCKER_REGISTRY_PASSWORD }}
- name: Build and push Docker images
- name: Build and push ssh-client Docker images
id: push
uses: docker/build-push-action@v6
with:
context: .
file: ${{gitea.workspace}}/docker/Dockerfile
file: ${{gitea.workspace}}/docker/ssh-client/Dockerfile
push: true
tags: git.limbosolutions.com/kb/ssh-client
- name: Build ssh-server images
run: |
cd ${{gitea.workspace}}/ssh-server
BUILD_ENV=prod ./scripts/build.sh

View File

@@ -1,4 +1,4 @@
FROM ubuntu:latest
RUN apt-get update && apt-get upgrade -y
RUN apt-get install -y openssh-client --fix-missing
RUN apt-get install -y openssh-server --fix-missing

1
docker/ssh-server/.env Normal file
View File

@@ -0,0 +1 @@
SSH_PORT="2222"

View File

@@ -0,0 +1,6 @@
#DEBUG_FILE="sshserver.py"
#DEBUG_FILE="sshserver.py"
#DEBUG_FILE="users.py"
SSH_SERVER_ENABLED="true"
CONTAINER_TAG="ssh-server:dev"

27
docker/ssh-server/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,27 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Remote Attach",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "localhost",
"port": 5678
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}/app",
"remoteRoot": "/app"
}
],
"justMyCode": false,
"preLaunchTask": "debug"
}
]
}

26
docker/ssh-server/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,26 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "debug",
"type": "shell",
"command": "RUN_ENV=dev ./scripts/run-dev.sh",
"problemMatcher": [],
"detail": "Runs the container with environment from .env"
},
{
"label": "build: debug",
"type": "shell",
"command": "BUILD_ENV=dev ./scripts/build.sh",
"problemMatcher": [],
"detail": "Builds docker image with debug requirements"
},
{
"label": "build: production",
"type": "shell",
"command": "./scripts/build.sh",
"problemMatcher": [],
"detail": "Builds docker image for productions"
}
]
}

View File

@@ -0,0 +1,48 @@
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
# ┃ 🐳 Dockerfile: Multi-Stage Build for Python + SSH App ┃
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
# ──────────────── Stage 1: Base ────────────────
# Base image using Debian Bullseye
FROM debian:bullseye as base
# Suppress interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Install core dependencies:
# - Python 3 and pip
# - OpenSSH server for remote access
# - sudo for privilege escalation
RUN apt-get update && \
apt-get install -y \
python3 \
python3-pip \
openssh-server \
sudo && \
mkdir /var/run/sshd && \
apt-get clean
# Create config directory for app (can be used by dev/prod stages)
RUN mkdir -p /etc/app/config
# ──────────────── Stage 2: Prod ────────────────
# Production stage inherits from base
FROM base AS prod
# Copy full app code into image (self-contained)
COPY app/ /app
# Set working directory
WORKDIR /app
RUN pip install --no-cache-dir -r requirements.txt
ENV CONFIGURATION=Production
ENV DEBUG=False
# Default command for production container
CMD ["python3", "/app/main.py"]
#EXPOSE 22

View File

@@ -0,0 +1,75 @@
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
# ┃ 🐳 Dockerfile: Multi-Stage Build for Python + SSH App /(dev) ┃
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
# ──────────────── Stage 1: Base ────────────────
# Base image using Debian Bullseye
FROM debian:bullseye as base
# Suppress interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Install core dependencies:
# - Python 3 and pip
# - OpenSSH server for remote access
# - sudo for privilege escalation
RUN apt-get update && \
apt-get install -y \
python3 \
python3-pip \
openssh-server \
sudo && \
mkdir /var/run/sshd && \
apt-get clean
# Create config directory for app (can be used by dev/prod stages)
RUN mkdir -p /etc/app/config
# ──────────────── Stage 2: Dev ────────────────
# Development stage inherits from base
FROM base AS dev
# Install debugging tools (e.g. debugpy for remote debugging)
RUN echo "Installing debug tools..." && \
pip install debugpy
# Set working directory
WORKDIR /app
# Install Python dependencies from requirements.txt
COPY app/requirements.txt requirements.txt
# This allows caching of dependencies even if app code is mounted
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 5678
# runtime environment
ENV CONFIGURATION=Debug
ENV DEBUG=True
# Default command for dev container
CMD ["python3", "/app/main.py"]
# ──────────────── Optional: User & SSH Setup ────────────────
# Uncomment below to create a non-root user and configure SSH access
# Create a non-root user with sudo privileges
# RUN useradd -m -s /bin/bash devuser && \
# echo "devuser:devpass" | chpasswd && \
# usermod -aG sudo devuser
# Configure SSH server to allow password login and specific users
# RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin no/' /etc/ssh/sshd_config && \
# sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config && \
# echo "AllowUsers devuser" >> /etc/ssh/sshd_config
# Expose SSH port

View File

@@ -0,0 +1,24 @@
# ssh-server
## dev and testing
```bash
```
```bash
podman container exec -it ssh-server-dev bash -
```
```bash
ssh root@0.0.0.0 -p 2333
```
# ssh-server
## dev and testing
Using vscode, check .vscode folder build and debug tasks as launch settings.

1
docker/ssh-server/app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__

View File

@@ -0,0 +1,25 @@
import os
import yaml
def is_debugging(): return os.getenv("CONFIGURATION") == "Debug"
file_path="/etc/app/config.yaml"
config=None
def config_exits():
global config
return config is not None
def sshserver_enabled():
return not is_debugging() or os.getenv("SSH_SERVER_ENABLED") == "true"
def get_config():
global config
return config
def load_config():
global config
if os.path.exists(file_path):
with open(file_path, 'r') as f:
config = yaml.safe_load(f)

View File

@@ -0,0 +1,43 @@
import os
import subprocess
import globals
import sys
def main():
if globals.is_debugging():
import debugpy
debugpy.listen(("0.0.0.0", 5678))
print("INFO: Waiting for debugger to attach...")
debugpy.wait_for_client()
print("INFO: Debugger attached")
else:
print("👉 starting setup")
globals.load_config()
setup_container()
if globals.is_debugging():
debug_file = os.getenv("DEBUG_FILE", "")
if debug_file and os.path.isfile(debug_file):
print(f"🔍 Running debug file: {debug_file}")
subprocess.run(["python3", debug_file], check=True, stdout=sys.stdout, stderr=sys.stderr)
return False
elif debug_file:
print(f"⚠️ DEBUG_FILE set but file not found: {debug_file}")
return True
def setup_container():
marker_file = "/var/run/container_initialized"
if not os.path.exists(marker_file):
print("INFO: First-time setup running...")
# 👉 Your one-time setup logic here
# Create the marker file
with open(marker_file, "w") as f:
f.write("initialized\n")
else:
print("TRACE: Already initialized. Skipping setup.")

View File

@@ -0,0 +1,14 @@
import globals
import subprocess
import init
import users
import sshserver
if init.main():
users.load()
sshserver.load()

View File

@@ -0,0 +1 @@
PyYAML>=5.4

View File

@@ -0,0 +1,120 @@
import yaml
import subprocess
import crypt
import os
import globals
import sys
config_file_path='/etc/ssh/sshd_config'
def set_sshd_option(file_path: str, key: str, value: str) -> None:
updated = False
lines = []
with open(file_path, 'r') as f:
for line in f:
if line.strip().startswith(key):
lines.append(f"{key} {value}\n")
updated = True
else:
lines.append(line)
if not updated:
lines.append(f"{key} {value}\n")
with open(file_path, 'w') as f:
f.writelines(lines)
print(f"✅ Updated {key} to '{value}' in {file_path}")
def load():
setup()
print_server_config()
if globals.sshserver_enabled():
start_server()
def setup_certs():
certs=[
"/etc/ssh/certs/ssh_host_rsa_key",
"/etc/ssh/certs/ssh_host_ecdsa_key",
"/etc/ssh/certs/ssh_host_ed25519_key"
]
if not os.path.exists("/etc/ssh/certs"):
os.makedirs("/etc/ssh/certs")
print(f"📁 Created folder: /etc/ssh/certs")
if not os.listdir("/etc/ssh/certs"):
subprocess.run([
"ssh-keygen", "-t", "rsa", "-f",
"/etc/ssh/certs/ssh_host_rsa_key"
], check=True, stdout=sys.stdout, stderr=sys.stderr)
print(f"✅ RSA key and certificate created:🔑 /etc/ssh/certs/ssh_host_rsa_key")
subprocess.run([
"ssh-keygen", "-t", "ecdsa", "-f",
"/etc/ssh/certs/ssh_host_ecdsa_key"
], check=True, stdout=sys.stdout, stderr=sys.stderr)
print(f"✅ RSA key and certificate created:🔑 /etc/ssh/certs/ssh_host_ecdsa_key")
subprocess.run([
"ssh-keygen", "-t", "ed25519", "-f",
"/etc/ssh/certs/ssh_host_ed25519_key"
], check=True, stdout=sys.stdout, stderr=sys.stderr)
print(f"✅ RSA key and certificate created:🔑 /etc/ssh/certs/ssh_host_ed25519_key")
certLines=[]
for cert in certs:
if os.path.exists(cert):
certLines.append(f"HostKey {cert}\n")
else:
print(f"❌ HostKey path not found {cert}")
if not certLines: RuntimeError("❌ Missing server certificates configuration. Bind Volume to /etc/ssh/certs")
lines = []
with open(config_file_path, 'r') as f:
for line in f:
if line.strip().startswith("HostKey"):
continue # remove existing HostKey lines
lines.append(line)
for key in certLines:
print(f"✅ HostKey path updated to use {key}")
lines.append(key)
with open(config_file_path, 'w') as f:
f.writelines(lines)
def setup():
global config_file_path
serverConfig = globals.get_config().get("server") if globals.config_exits() else None
if not serverConfig:
return
optionsConfig = serverConfig.get("options")
if optionsConfig:
for option in optionsConfig:
set_sshd_option(config_file_path, option, optionsConfig[option])
setup_certs()
def print_server_config():
with open(config_file_path, 'r') as f:
content = f.read()
print(content)
def start_server():
print("INFO: Starting ssh server.")
subprocess.run(["/usr/sbin/sshd", "-D", "-e"])
if __name__ == "__main__":
globals.load_config()
load()

View File

@@ -0,0 +1,89 @@
import yaml
import subprocess
import crypt
import os
import globals
import pwd
# users:
# - username: alice
# authorized_keys: publich ssh key
# - username: bob
# password: hunter2
def user_exists(username):
try:
pwd.getpwnam(username)
return True
except KeyError:
return False
def create_user(uid, username ,password, shell="/bin/bash"):
if not shell: shell = "/bin/bash"
if not username:
return
useradd_cmd = [
'useradd',
'-m',
'-s', shell,
]
if uid: useradd_cmd.append("-u " + str(uid))
if password: useradd_cmd.append("-p" + crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512)))
useradd_cmd.append(username)
try:
subprocess.run(useradd_cmd
, check=True)
print(f"✅ User '{username}' created with shell '{shell}' and password.")
except subprocess.CalledProcessError as e:
print(f"❌ Failed to create user '{username}': {e}")
def setup_ssh(username, public_key):
ssh_dir = f"/home/{username}/.ssh"
auth_keys = os.path.join(ssh_dir, "authorized_keys")
uid = pwd.getpwnam(username).pw_uid
gid = pwd.getpwnam(username).pw_gid
os.makedirs(ssh_dir, mode=0o700, exist_ok=True)
# Check if key already exists
key_exists = False
if os.path.exists(auth_keys):
with open(auth_keys, "r") as f:
existing_keys = f.read().splitlines()
key_exists = public_key.strip() in existing_keys
if not key_exists:
with open(auth_keys, "a") as f:
f.write(public_key.strip() + "\n")
print(f"🔐 SSH key added for '{username}'.")
else:
print(f"⚠️ SSH key already exists for '{username}'. Skipping.")
os.chmod(ssh_dir, 0o700)
os.chmod(auth_keys, 0o600)
os.chown(ssh_dir, uid, gid)
os.chown(auth_keys, uid, gid)
def load():
users = globals.get_config()["users"] if globals.config_exits() else None
if users:
for user in users:
if not user_exists(user.get('username')):
create_user(user.get('uid'), user.get('username'),user.get('password'), user.get('shell'))
if user.get('public_keys'):
for public_key in user.get('public_keys'):
setup_ssh(user.get('username'), public_key)
if __name__ == "__main__":
globals.load_config()
load()

View File

@@ -0,0 +1 @@
BUILD_ENV_IMAGE_TAG="git.limbosolutions.com/kb/ssh-server:latest"

View File

@@ -0,0 +1,2 @@
BUILD_ENV_IMAGE_TAG="ssh-server:dev"
BUILD_ENV_DOCKER_FILE="Dockerfile.dev"

View File

@@ -0,0 +1,92 @@
#!/bin/bash
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
# ┃ 🐳 Podman Build Script with Layered .env Configuration ┃
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
#
# This script builds a Podman image using a specified Dockerfile,
# dynamically injecting build-time environment variables from a
# prioritized set of `.env` files.
#
# ──────────────── Behavior ────────────────
# - Loads variables from the following files (in order):
# build.env
# build.env.${BUILD_ENV}
# build.local.env
#
# - Later files override earlier ones (last-write-wins)
# - Variables starting with `BUILD_ENV` are excluded from build args
#
# ──────────────── Required Environment Variables ────────────────
# - BUILD_ENV: (optional) Environment name (e.g. dev, prod)
# - BUILD_ENV_DOCKER_FILE: Dockerfile to use (default: Dockerfile)
# - BUILD_ENV_IMAGE_TAG: Tag to assign to the built image
#
# ──────────────── Example Usage ────────────────
# BUILD_ENV=dev BUILD_ENV_IMAGE_TAG=myapp:dev ./build.sh
# BUILD_ENV=prod BUILD_ENV_DOCKER_FILE=Dockerfile.prod BUILD_ENV_IMAGE_TAG=myapp:prod ./build.sh
#
# ──────────────── Output ────────────────
# - Logs each loaded file
# - Displays the final build command
# - Executes the podman build with all resolved --build-arg flags
#
# Author: Márcio
# Last Updated: September 2025
POSSIBLE_BUILD_ARGS_FILES="\
build.env \
"
if [ -n "${BUILD_ENV+x}" ]; then
POSSIBLE_BUILD_ARGS_FILES="$POSSIBLE_BUILD_ARGS_FILES \
build.env.${BUILD_ENV} \
"
fi
POSSIBLE_BUILD_ARGS_FILES="$POSSIBLE_BUILD_ARGS_FILES \
.build.local"
# load variables into this context
declare -A build_args_map
BUILD_ARGS=""
for file in $POSSIBLE_BUILD_ARGS_FILES; do
if [ -f "$file" ]; then
echo "🔧 Loading variables from $file"
set -a
source "$file"
set +a
while IFS= read -r line || [ -n "$line" ]; do
# Skip comments and empty lines
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
# Extract key and value
key="$(echo "$line" | cut -d= -f1 | xargs)"
value="$(echo "$line" | cut -d= -f2- | xargs)"
# Skip keys that start with BUILD_ENV
if [[ "$key" == BUILD_ENV* ]]; then
echo "Skipping key starting with BUILD_ENV: $key"
continue
fi
build_args_map["$key"]="$value"
done < "$file"
else
echo "⚠️ Skipping missing file: $file"
fi
done
for key in "${!build_args_map[@]}"; do
BUILD_ARGS+=" --build-arg $key=${build_args_map[$key]}"
done
# sets default docker file
BUILD_ENV_DOCKER_FILE="${BUILD_ENV_DOCKER_FILE:-Dockerfile}"
echo "Build env: $BUILD_ENV"
echo "Build DockerFile: $BUILD_ENV_DOCKER_FILE"
echo "Build Tag: $BUILD_ENV_IMAGE_TAG"
echo "Running: podman build -f $BUILD_ENV_DOCKER_FILE $BUILD_ARGS -t $BUILD_ENV_IMAGE_TAG ."
podman build -f $BUILD_ENV_DOCKER_FILE $BUILD_ARGS -t $BUILD_ENV_IMAGE_TAG .

View File

@@ -0,0 +1,39 @@
#!/bin/bash
RUN_ENV=dev
POSSIBLE_ENV_FILES="\
.env
"
if [ -n "${RUN_ENV+x}" ]; then
POSSIBLE_ENV_FILES="$POSSIBLE_ENV_FILES \
.env.${RUN_ENV} \
"
fi
POSSIBLE_ENV_FILES="$POSSIBLE_ENV_FILES \
.env.local"
for file in $POSSIBLE_ENV_FILES; do
if [ -f "$file" ]; then
echo "🔧 Loading variables from $file"
set -a
source "$file"
set +a
else
echo "⚠️ Skipping missing file: $file"
fi
done
# Ignored if uing dev docker file:
# - DEBUG_FILE
# - SSH_SERVER_ENABLED
podman container run -d --rm \
-e DEBUG_FILE="${DEBUG_FILE}" \
-e SSH_SERVER_ENABLED="${SSH_SERVER_ENABLED:-false}" \
-p 2222:22 \
-p 5678:5678 \
-v ./app:/app \
-v ./local/config:/etc/app \
-v ./local/server-certs:/etc/ssh/certs \
${CONTAINER_TAG}