From 98b53d53f4314983a9084b268267ed25be189e6c Mon Sep 17 00:00:00 2001 From: Dmitry Fedotov Date: Sun, 1 Feb 2026 22:21:59 +0300 Subject: [PATCH] initial --- Dockerfile | 11 ++++ LICENSE | 19 ++++++ Makefile | 18 ++++++ README.md | 89 ++++++++++++++++++++++++++++ entrypoint.py | 156 +++++++++++++++++++++++++++++++++++++++++++++++++ sysconfig.toml | 30 ++++++++++ 6 files changed, 323 insertions(+) create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 entrypoint.py create mode 100644 sysconfig.toml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e714c34 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM alpine:3.22 + +RUN apk add --no-cache python3 shadow + +COPY --chmod=0755 entrypoint.py /bin/entrypoint.py + +VOLUME /etc/sysconfig.toml + +ENTRYPOINT ["/bin/entrypoint.py"] + +CMD ["/bin/sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f166d1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026 Dmitry Fedotov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1155d6d --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: all build push + +IMAGE_NAME := alpine-customizable +IMAGE_VERSION := v0.1.0 +IMAGE_TAG := $(IMAGE_NAME):$(IMAGE_VERSION) +REMOTE_TAG = registry.uint32.ru/$(IMAGE_TAG) + + +all: build + +build: + @echo "Building Docker image: $(IMAGE_TAG)" + docker build -t $(IMAGE_TAG) . + +push: + @echo "Pushing Docker image: $(REMOTE_TAG)" + docker tag $(IMAGE_TAG) $(REMOTE_TAG) + docker push $(REMOTE_TAG) diff --git a/README.md b/README.md new file mode 100644 index 0000000..71b4278 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Custom Alpine Image + +This repository contains the necessary files to build a customizable Alpine Linux Docker image. The image can be configured at runtime by providing a `sysconfig.toml` file. + +## How to build the image + +To build the Docker image, run the following command from the root of this repository: + +```sh +docker build -t alpine-customizable . +``` + +## How to configure the image + +You can configure the container at runtime by mounting a `sysconfig.toml` file at `/etc/sysconfig.toml`. + +```sh +docker run -it --rm -v ./sysconfig.toml:/etc/sysconfig.toml alpine-customizable +``` + +The `sysconfig.toml` file supports the following sections for configuration: + +### `[general]` + +This section is used for general system-wide settings. + +- `packages`: A list of strings specifying additional Alpine packages to install using `apk add`. + + *Example:* + ```toml + [general] + packages = ["openssh-server", "curl"] + ``` + +### `[users]` + +This section allows you to define users that will be created on the container. Each user is defined in a sub-section using the format `[users.username]`. + +The following keys are supported for each user: + +- `password` (optional): A string to set the user's password. +- `pubkeys` (optional): A list of public SSH keys (strings) to add to the user's `~/.ssh/authorized_keys` file, enabling key-based authentication. + + *Example:* + ```toml + [users.dmitry] + password = "a-secure-password" + pubkeys = [ + "ssh-rsa AAAA...", + "ssh-ed25519 AAAA..." + ] + ``` + +### `[groups]` + +This section allows you to define groups and manage their members. Each group is defined in a sub-section using the format `[groups.groupname]`. + +The following keys are supported for each group: + +- `users`: A list of usernames to be added to this group. These users should typically be defined in the `[users]` section or already exist on the system. + + *Example:* + ```toml + [groups.sftp-users] + users = ["dmitry"] + ``` + +### `[configs]` + +This section allows you to create arbitrary configuration files on the container's filesystem. Each file is defined in a sub-section where the name is the full, quoted path to the file, e.g., `[configs."/etc/motd"]`. + +The following keys are supported for each file: + +- `body` (required): A string (often a multi-line string) containing the content of the file. +- `permissions` (optional): An integer representing the file permissions in standard Linux octal notation (e.g., `644`, `755`). +- `owner` (optional): A string in `"user:group"` format to set the file's ownership. + + *Example:* + ```toml + [configs."/etc/ssh/sshd_config.d/sftp.conf"] + body = """ + Match group sftp-users + ChrootDirectory /chroot + ForceCommand internal-sftp + AllowTcpForwarding no + """ + permissions = 644 + owner = "root:root" + ``` diff --git a/entrypoint.py b/entrypoint.py new file mode 100644 index 0000000..7c8f1c2 --- /dev/null +++ b/entrypoint.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +import os +import shutil +import subprocess +import sys + +import tomllib + +SYSCONFIG_PATH = "/etc/sysconfig.toml" + +# Config sections +SECTION_GENERAL = "general" +SECTION_USERS = "users" +SECTION_GROUPS = "groups" +SECTION_CONFIGS = "configs" + +# Config keys +KEY_PACKAGES = "packages" +KEY_PASSWORD = "password" +KEY_PUBKEYS = "pubkeys" +KEY_USERS_LIST = "users" +KEY_BODY = "body" +KEY_PERMISSIONS = "permissions" +KEY_OWNER = "owner" + + +class SystemConfigurator: + def __init__(self, path: str): + self.config_path = path + + def _parse_config(self): + with open(self.config_path, "rb") as f: + self.config = tomllib.load(f) + + def _exec_command(self, command: list[str], quiet=False) -> int: + return subprocess.run(command, capture_output=quiet).returncode + + def _install_packages(self): + packages = self.config.get(SECTION_GENERAL, {}).get(KEY_PACKAGES, []) + if not packages: + print("No packages to install.") + return + print(f"Installing packages: {', '.join(packages)}") + command = ["apk", "add", "--no-cache"] + packages + if self._exec_command(command) != 0: + raise RuntimeError(f"Failed to install packages: {', '.join(packages)}") + print("Packages installed successfully.") + + def _create_users(self): + users = self.config.get(SECTION_USERS, {}) + if not users: + print("No users to create.") + return + for username, user_config in users.items(): + print(f"Creating user: {username}") + if self._exec_command(["useradd", "-m", "-s", "/bin/sh", username]) != 0: + raise RuntimeError(f"Failed to create user: {username}") + if KEY_PASSWORD in user_config: + print(f"Setting password for user: {username}") + proc = subprocess.run( + ["chpasswd"], + input=f"{username}:{user_config[KEY_PASSWORD]}".encode(), + capture_output=True, + ) + if proc.returncode != 0: + raise RuntimeError( + f"Failed to set password for user: {username}: {proc.stderr.decode()}" + ) + if KEY_PUBKEYS in user_config: + print(f"Adding SSH key for user: {username}") + ssh_dir = os.path.join("/home", username, ".ssh") + os.makedirs(ssh_dir, mode=0o700, exist_ok=True) + authorized_keys_path = os.path.join(ssh_dir, "authorized_keys") + with open(authorized_keys_path, "w") as f: + for key in user_config[KEY_PUBKEYS]: + f.write(key + "\n") + os.chmod(authorized_keys_path, 0o600) + shutil.chown(ssh_dir, user=username, group=username) + shutil.chown(authorized_keys_path, user=username, group=username) + print(f"User {username} created successfully.") + + def _create_groups(self): + groups = self.config.get(SECTION_GROUPS, {}) + if not groups: + print("No groups to create.") + return + for groupname, group_config in groups.items(): + if self._exec_command(["getent", "group", groupname], quiet=True) == 0: + print(f"Group '{groupname}' already exists, skipping creation.") + else: + print(f"Creating group: {groupname}") + if self._exec_command(["groupadd", groupname]) != 0: + raise RuntimeError(f"Failed to create group: {groupname}") + + users = group_config.get(KEY_USERS_LIST, []) + for username in users: + print(f"Adding user {username} to group {groupname}") + if self._exec_command(["gpasswd", "-a", username, groupname]) != 0: + raise RuntimeError( + f"Failed to add user {username} to group {groupname}" + ) + print(f"Group '{groupname}' configured successfully.") + + def _create_configs(self): + configs = self.config.get(SECTION_CONFIGS, {}) + if not configs: + print("No configs to create.") + return + for file_path, config in configs.items(): + print(f"Creating config file: {file_path}") + os.makedirs(os.path.dirname(file_path), exist_ok=True) + if KEY_BODY not in config: + raise RuntimeError( + f"Config file '{file_path}' is missing the mandatory '{KEY_BODY}' key." + ) + with open(file_path, "w") as f: + f.write(config[KEY_BODY]) + if KEY_PERMISSIONS in config: + os.chmod(file_path, int(str(config[KEY_PERMISSIONS]), 8)) + if KEY_OWNER in config: + owner_val = config[KEY_OWNER] + if ":" not in str(owner_val): + raise RuntimeError( + f"Config file '{file_path}' has an invalid '{KEY_OWNER}' format. Expected 'user:group'." + ) + user, group = str(owner_val).split(":", 1) + shutil.chown(file_path, user=user, group=group) + print(f"Config file {file_path} created successfully.") + + def process(self) -> int: + steps = [ + (self._parse_config, 1), + (self._install_packages, 2), + (self._create_users, 3), + (self._create_groups, 4), + (self._create_configs, 5), + ] + + for func, code in steps: + try: + func() + except Exception as e: + print(e, file=sys.stderr) + return code + + return 0 + + +if __name__ == "__main__": + configurator = SystemConfigurator(SYSCONFIG_PATH) + exit_code = configurator.process() + if exit_code != 0: + sys.exit(exit_code) + + if len(sys.argv) > 1: + os.execvp(sys.argv[1], sys.argv[1:]) diff --git a/sysconfig.toml b/sysconfig.toml new file mode 100644 index 0000000..b8d85c0 --- /dev/null +++ b/sysconfig.toml @@ -0,0 +1,30 @@ +[ general ] +packages = [ + "openssh-server", +] + +[ users.dmitry ] +password = "1234" +pubkeys = [ + "the key", + "the second key", +] + +[ users.nadia ] +password = "2345" +pubkeys = [ + "the key2", +] + +[groups.sftp-users] +users = ["dmitry"] + +[configs."/etc/ssh/sshd_config.d/sftp.conf"] +body = """ +Match group sftp-users + ChrootDirectory /chroot + ForceCommand internal-sftp + AllowTcpForwarding no +""" +permissions = 644 +owner = "root:root"