This commit is contained in:
2026-02-01 22:21:59 +03:00
commit 98b53d53f4
6 changed files with 323 additions and 0 deletions

11
Dockerfile Normal file
View File

@@ -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"]

19
LICENSE Normal file
View File

@@ -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.

18
Makefile Normal file
View File

@@ -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)

89
README.md Normal file
View File

@@ -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"
```

156
entrypoint.py Normal file
View File

@@ -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:])

30
sysconfig.toml Normal file
View File

@@ -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"