diff --git a/.forgejo/workflows/build-release.yaml b/.forgejo/workflows/build-release.yaml index 48e5e92..600b9b0 100644 --- a/.forgejo/workflows/build-release.yaml +++ b/.forgejo/workflows/build-release.yaml @@ -56,37 +56,3 @@ jobs: run: pip install twine - name: Upload package to registry run: python -m twine upload --repository-url ${{ secrets.REPOSITORY_URL }} -u ${{ secrets.TWINE_DEPLOY_USER }} -p ${{ secrets.TWINE_DEPLOY_PASSWORD }} dist/* - - build-and-push-container: - needs: [ "publish-artifacts" ] - runs-on: dind - steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Log into Docker Package Registry - uses: docker/login-action@v3 - with: - registry: forgejo.neshweb.net - username: ${{ secrets.FORGEJO_USERNAME }} - password: ${{ secrets.FORGEJO_TOKEN }} - - name: Build and push to Docker Package Registry - uses: docker/build-push-action@v5 - with: - build-args: | - PACKAGE_VERSION=${{ github.ref_name }} - push: true - tags: forgejo.neshweb.net/firq/dockge-cli:${{ github.ref_name }} - - release: - needs: [ build-and-push-container, publish-artifacts ] - if: success() - runs-on: docker - steps: - - name: Release New Version - uses: actions/forgejo-release@v1 - with: - direction: upload - url: https://forgejo.neshweb.net - release-dir: release - token: ${{ secrets.FORGEJO_TOKEN }} - tag: ${{ github.ref_name }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 88e754d..0000000 --- a/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11 - -ARG PACKAGE_VERSION=0.1.0 -RUN pip install dockge-cli==${PACKAGE_VERSION} diff --git a/README.md b/README.md index 2f946e3..4c51468 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,3 @@ # dockge-cli A simple CLI application written in Python for communicating with Dockge using websockets - -## Background - -Dockge (spoken dock-ge or dockage) is a tool to manage docker-compose stacks from a web ui. It is developed by louislam, who also develops UptimeKuma. - -Dockge itself doesn't offer any kind of API or programmatic access, as it is just intended for managing stacks via UI. - -My current deployment solution for firq.dev and fgo-ta.com is based on Dockge, and I was over it always having to reload the stack whenever I pushed an update. Instead, I wanted to have this as a separate CI step, automatically redeploying a givens stack. - -As Dockge is using a websocket-based system under the hood, it was easy to take a look at how communication occurs. In general, communication is achieved by leveraging socket.io for the data. Since Python already offers a solution for socket.io, it is just a matter of emulating the calls the webui sends and receives. - -In the end, this is the current result that works pretty well for my understanding. I am still trying to improve upon some issues (login times out, stability, features), but in general this works as a fine solution for automatic stack updating. - -## Installation - -Install it from the custom package index using - -```shell -pip install --extra-index-url https://forgejo.neshweb.net/api/packages/Firq/pypi/simple/ dockge-cli -``` - -Alternativly, install it using this repository. When installing for development, make sure to install with the additional dependencies - -```shell -pip install -e .[lint,typing] -``` - -## Usage - -Call the CLI using `dockge-cli` or `dockge`. - -```shell -usage: dockge_cli [-h] [--version] {host,login,logout,list,status,restart,start,stop,down,update,exit,help} - -CLI interface for interacting with Dockge - -positional arguments: - {host,login,logout,list,status,restart,start,stop,down,update,exit,help} - -options: - -h, --help show this help message and exit - --version show program's version number and exit -``` - -Help for each individual command can be invoked by calling `dockge-cli help <command>` - -## The magic behind this - -Generally, this makes use of the underlying Websockets API that the Dockge frontend uses to communicate with the server. By analyzing the traffic and looking into the codebase, I was able to reverse most of the packets that are being sent. This allows me to then contruct, send and receive my own packets, making the whole thing work. - -There are some things that need to be taken into account for this: For one, dockge uses socket.io for the websocket communication. This meant I had to find the corresponding socket.io version to get the correct version of python-socketio. In addition, I had to find out how the authorization mechanism behind this works. - -After finishing up the first prototype, the workings are as follows: - -1. A websocket session is established using socket.io - this happens automatically -2. After the session is ready, the `login` command is sent together with a provided username and password -3. Once the CLI is authorized, the selected command is sent -4. The CLI waits for any response values and exits once the command has executed successfully - -To provide a smooth experience, both the credentials and the remote host URI are stored on disk. just like the `docker` cli, the credentials are not encrypted, meaning it is advised to either clear the credentials after use OR to use the `--username` and `--password` parameters. This is especially recommended for CI applications. - -## Known issues - -This CLI does not work when Mullvad is used, as Mullvad actively blocks port forwarding (which python-socketio uses) diff --git a/dockge_cli/.gitignore b/dockge_cli/.gitignore new file mode 100644 index 0000000..d36a0d4 --- /dev/null +++ b/dockge_cli/.gitignore @@ -0,0 +1 @@ +.temp/* \ No newline at end of file diff --git a/dockge_cli/client/commandprovider/__init__.py b/dockge_cli/client/commandprovider/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dockge_cli/client/commands/functions.py b/dockge_cli/client/commandprovider/bindings.py similarity index 73% rename from dockge_cli/client/commands/functions.py rename to dockge_cli/client/commandprovider/bindings.py index 3ee695d..c29c3ab 100644 --- a/dockge_cli/client/commands/functions.py +++ b/dockge_cli/client/commandprovider/bindings.py @@ -1,22 +1,18 @@ from urllib.parse import urlparse from getpass import getpass -import re from ...models import Credentials from ...service import storage -from ...service.connection import DockgeConnection -from ..utils import stack_formatter, status_formatter, generic_formatter, credential_parser_factory +from ...service.communicate import DockgeConnection +from ..utils import stack_formatter, status_formatter, generic_formatter, get_credential_parser -class FunctionBindings(): +class ExecutionCommands(): """ Helper class that provides all the static methods in an organized way This is an abstraction layer of the CLI, as those functions only do little preprocessing before calling the actural DockgeConnection """ @staticmethod def __setup(): - """ - Creates a connection and logs into Dockge - """ con = DockgeConnection() con.connect_and_login() return con @@ -27,12 +23,10 @@ class FunctionBindings(): host command binding """ if len(extra_args) > 0: - mat = re.search(r"((\w+\.)?\w+\.\w+(\/.+)?)", extra_args[0], re.IGNORECASE) - if mat is None: - raise ValueError("Given host did not match regex") - res = urlparse(f"https://{mat[0]}") + res = urlparse(extra_args[0]) if all([res.scheme, res.netloc]): - storage.put("host", mat[0]) + host = extra_args[0].rstrip("/").replace("https://", "").replace("wss://", "") + storage.put("host", host) else: raise ValueError(f"Malformed URL {extra_args[0]}") print(storage.get("host")) @@ -42,9 +36,8 @@ class FunctionBindings(): """ login command binding """ - print(f"WARNING! These credentials will be saved unencrypted in {storage._file.absolute()}") if len(extra_args) > 0: - credentials = credential_parser_factory().parse_args(extra_args, namespace=Credentials) + credentials = get_credential_parser().parse_args(extra_args, namespace=Credentials) storage.put("username", credentials.username, encoded=True) storage.put("password", credentials.password, encoded=True) return @@ -71,7 +64,7 @@ class FunctionBindings(): """ list command binding """ - con = FunctionBindings.__setup() + con = ExecutionCommands.__setup() stack_formatter(con.list_stacks()) con.disconnect() @@ -80,7 +73,9 @@ class FunctionBindings(): """ status command binding """ - con = FunctionBindings.__setup() + if extra_args is None: + raise ValueError + con = ExecutionCommands.__setup() status_formatter(con.list_stack(extra_args[0])) con.disconnect() @@ -89,7 +84,9 @@ class FunctionBindings(): """ restart command binding """ - con = FunctionBindings.__setup() + if extra_args is None: + raise ValueError + con = ExecutionCommands.__setup() generic_formatter(con.restart(extra_args[0])) con.disconnect() @@ -98,7 +95,9 @@ class FunctionBindings(): """ update command binding """ - con = FunctionBindings.__setup() + if extra_args is None: + raise ValueError + con = ExecutionCommands.__setup() generic_formatter(con.update(extra_args[0])) con.disconnect() @@ -107,7 +106,9 @@ class FunctionBindings(): """ stop command binding """ - con = FunctionBindings.__setup() + if extra_args is None: + raise ValueError + con = ExecutionCommands.__setup() generic_formatter(con.stop(extra_args[0])) con.disconnect() @@ -116,7 +117,9 @@ class FunctionBindings(): """ start command binding """ - con = FunctionBindings.__setup() + if extra_args is None: + raise ValueError + con = ExecutionCommands.__setup() generic_formatter(con.start(extra_args[0])) con.disconnect() @@ -125,7 +128,9 @@ class FunctionBindings(): """ down command binding """ - con = FunctionBindings.__setup() + if extra_args is None: + raise ValueError + con = ExecutionCommands.__setup() generic_formatter(con.down(extra_args[0])) con.disconnect() diff --git a/dockge_cli/client/commands/mappings.py b/dockge_cli/client/commandprovider/descriptors.py similarity index 79% rename from dockge_cli/client/commands/mappings.py rename to dockge_cli/client/commandprovider/descriptors.py index a6ca52f..10e60f3 100644 --- a/dockge_cli/client/commands/mappings.py +++ b/dockge_cli/client/commandprovider/descriptors.py @@ -1,6 +1,6 @@ from typing import List from ...models import Command -from .functions import FunctionBindings +from .bindings import ExecutionCommands mapping: List[Command] = [ Command( @@ -8,83 +8,83 @@ mapping: List[Command] = [ description="Sets and gets the URI of the dockge instance. Remove any unnecessary subdomains/protocols from the URI", args=1, optional=True, - func=FunctionBindings.host + bind=ExecutionCommands.host ), Command( cmd="login", description="Logs into a given dockge account, either with an interactive dialogue or by passing --user and --password", args=2, optional=True, - func=FunctionBindings.login + bind=ExecutionCommands.login ), Command( cmd="logout", description="Removes the credentials from the local storage.", args=0, optional=False, - func=FunctionBindings.logout + bind=ExecutionCommands.logout ), Command( cmd="list", description="Lists all available stacks with their status", args=0, optional=False, - func=FunctionBindings.list + bind=ExecutionCommands.list ), Command( cmd="status", description="Returns the status of one stack", args=1, optional=False, - func=FunctionBindings.status + bind=ExecutionCommands.status ), Command( cmd="restart", description="Restarts a given stack", args=1, optional=False, - func=FunctionBindings.restart + bind=ExecutionCommands.restart ), Command( cmd="start", description="Starts a given stack", args=1, optional=False, - func=FunctionBindings.start + bind=ExecutionCommands.start ), Command( cmd="stop", description="Stops a given stack", args=1, optional=False, - func=FunctionBindings.stop + bind=ExecutionCommands.stop ), Command( cmd="down", description="Stop & Downs a given stack", args=1, optional=False, - func=FunctionBindings.down + bind=ExecutionCommands.down ), Command( cmd="update", description="Updates a stack", args=1, optional=False, - func=FunctionBindings.update + bind=ExecutionCommands.update ), Command( cmd="exit", description="Exits the CLI - this will reset all settings, including credentials and host", args=0, optional=False, - func=FunctionBindings.exit + bind=ExecutionCommands.exit ), Command( cmd="help", description="Displays helping hints for commands", args=1, optional=True, - func=FunctionBindings.help + bind=ExecutionCommands.help ) ] diff --git a/dockge_cli/client/commandprovider/factory.py b/dockge_cli/client/commandprovider/factory.py new file mode 100644 index 0000000..6bb2fbe --- /dev/null +++ b/dockge_cli/client/commandprovider/factory.py @@ -0,0 +1,7 @@ +from ...models import Command +from .descriptors import mapping + +commands: dict[str, Command] = {} + +for descriptor in mapping: + commands.update({ descriptor.cmd: descriptor }) diff --git a/dockge_cli/client/commands/__init__.py b/dockge_cli/client/commands/__init__.py deleted file mode 100644 index 61eb6fc..0000000 --- a/dockge_cli/client/commands/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from ...models import Command -from .mappings import mapping - -commands: dict[str, Command] = { c.cmd: c for c in mapping } diff --git a/dockge_cli/client/parser.py b/dockge_cli/client/parser.py index f84628b..9e68f3e 100644 --- a/dockge_cli/client/parser.py +++ b/dockge_cli/client/parser.py @@ -3,7 +3,7 @@ import sys from .. import __version__ from ..models import Arguments -from .commands import commands +from .commandprovider.factory import commands def parse_arguments(): """ diff --git a/dockge_cli/client/run.py b/dockge_cli/client/run.py index 22d0a2a..bd5e7d8 100644 --- a/dockge_cli/client/run.py +++ b/dockge_cli/client/run.py @@ -1,4 +1,4 @@ -from .commands import commands +from .commandprovider.factory import commands def display_help(extra_args): """ @@ -32,4 +32,4 @@ def run(command, args): display_help(args) return - c.func(args) + c.bind(args) diff --git a/dockge_cli/client/utils.py b/dockge_cli/client/utils.py index ebddf26..9ac97bc 100644 --- a/dockge_cli/client/utils.py +++ b/dockge_cli/client/utils.py @@ -2,7 +2,7 @@ import argparse from tabulate import tabulate from ..models import StackStatus -def credential_parser_factory(): +def get_credential_parser(): """ Creates a new parser for login credentials """ @@ -23,7 +23,7 @@ def stack_formatter(stacks): table, headers = [], ["Stackname", "Status"] for key, val in stacks["stackList"].items(): - table.append([key, StackStatus(val['status']).name.lower()]) + table.append([key, StackStatus(val['status']).name]) print(tabulate(table, headers=headers, tablefmt="github"), "\n") diff --git a/dockge_cli/models/__init__.py b/dockge_cli/models/__init__.py index ac7af80..e412edf 100644 --- a/dockge_cli/models/__init__.py +++ b/dockge_cli/models/__init__.py @@ -1,3 +1,3 @@ from .codes import StackStatus -from .command import Command +from .commands import Command from .parser import Arguments, Credentials diff --git a/dockge_cli/models/codes.py b/dockge_cli/models/codes.py index 569ce1d..4c3799b 100644 --- a/dockge_cli/models/codes.py +++ b/dockge_cli/models/codes.py @@ -2,8 +2,9 @@ from enum import Enum class StackStatus(Enum): """ - mapping for plaintext vs statuscode + mapping codes for status vs text """ - INACTIVE = 1 - RUNNING = 3 - EXITED = 4 + # pylint: disable=invalid-name + inactive = 1 + running = 3 + exited = 4 diff --git a/dockge_cli/models/command.py b/dockge_cli/models/commands.py similarity index 92% rename from dockge_cli/models/command.py rename to dockge_cli/models/commands.py index 61a13b3..d7a8123 100644 --- a/dockge_cli/models/command.py +++ b/dockge_cli/models/commands.py @@ -6,7 +6,7 @@ class Command(BaseModel): Basic command structure for the CLI to automatically generate valid commands """ cmd: str - func: Callable + bind: Callable args: int optional: bool description: str diff --git a/dockge_cli/service/.gitignore b/dockge_cli/service/.gitignore deleted file mode 100644 index 9e62042..0000000 --- a/dockge_cli/service/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.storage/* \ No newline at end of file diff --git a/dockge_cli/service/connection.py b/dockge_cli/service/communicate.py similarity index 86% rename from dockge_cli/service/connection.py rename to dockge_cli/service/communicate.py index 78e7582..b534174 100644 --- a/dockge_cli/service/connection.py +++ b/dockge_cli/service/communicate.py @@ -32,7 +32,7 @@ class DockgeConnection: def _init_events(self): @self._sio.event def connect(): - self.login() + self.connect() print("Connected!") @self._sio.event @@ -63,7 +63,6 @@ class DockgeConnection: success = True else: print("Issue with login procedure") - print(data) return success # Functions @@ -71,11 +70,10 @@ class DockgeConnection: """ Connect to the websocket """ - # Dockge uses Socket.io for the websockets, so this URI and params are always the same - self._sio.connect(f"https://{self._host}/socket.io/") - self.login() + self._sio.connect(f"https://{self._host}/socket.io/", transports=['websocket']) + self.connect() - def login(self): + def connect(self): """ Log into dockge using basicauth Retries 5 times when timeouts occur @@ -86,11 +84,6 @@ class DockgeConnection: data = None retry, count = True, 0 - if not storage.exists("username"): - raise ValueError("Missing username") - if not storage.exists("password"): - raise ValueError("Missing password") - while retry and count < 5: try: data = self._sio.call( @@ -98,13 +91,12 @@ class DockgeConnection: { "username": storage.get("username", encoded=True), "password": storage.get("password", encoded=True), - "token": "" + "token":"" }, - timeout=10 + timeout=5 ) retry = False except socketio.exceptions.TimeoutError: - print("Reached timeout for login, retrying ...") retry = True count += 1 @@ -127,42 +119,42 @@ class DockgeConnection: """ Lists status for a stack """ - ret = self._sio.call("agent", ("", "serviceStatusList", name), timeout=10) + ret = self._sio.call("agent", ("", "serviceStatusList", name), timeout=5) return ret def restart(self, name): """ Restarts a given stack """ - ret = self._sio.call("agent", ("", "restartStack", name), timeout=30) + ret = self._sio.call("agent", ("", "restartStack", name), timeout=10) return ret def update(self, name): """ Updates a given stack """ - ret = self._sio.call("agent", ("", "updateStack", name), timeout=30) + ret = self._sio.call("agent", ("", "updateStack", name), timeout=10) return ret def stop(self, name): """ Stops a given stack """ - ret = self._sio.call("agent", ("", "stopStack", name), timeout=30) + ret = self._sio.call("agent", ("", "stopStack", name), timeout=10) return ret def start(self, name): """ Starts a given stack """ - ret = self._sio.call("agent", ("", "startStack", name), timeout=30) + ret = self._sio.call("agent", ("", "startStack", name), timeout=10) return ret def down(self, name): """ Stops and downs a given stack """ - ret = self._sio.call("agent", ("", "downStack", name), timeout=30) + ret = self._sio.call("agent", ("", "downStack", name), timeout=10) return ret def disconnect(self): diff --git a/dockge_cli/service/storage.py b/dockge_cli/service/storage.py index 3edf415..e4276a2 100644 --- a/dockge_cli/service/storage.py +++ b/dockge_cli/service/storage.py @@ -3,44 +3,28 @@ import pathlib import base64 import yaml -_storagepath = pathlib.Path(__file__).parent / ".storage" -_storagepath.mkdir(exist_ok=True, parents=True) +_storagepath = pathlib.Path(__file__).parents[1] / ".temp" _file = _storagepath / "storage.yaml" -def create_file_when_missing(): +_storagepath.mkdir(exist_ok=True, parents=True) + +def fileexists(): """ Checks if storage file does exist, creates it when necessary """ - if _file.exists(): - return - with open(_file, 'a', encoding="utf-8"): - os.utime(_file, None) - -def exists(key: str) -> bool: - """ - Checks if a given key exists in the storage file - """ if not _file.exists(): - return False - - with open(_file, "r", encoding="utf-8") as file: - content: dict[str, str] = yaml.load(file, Loader=yaml.SafeLoader) - - return key in content - + with open(_file, 'a', encoding="utf-8"): + os.utime(_file, None) def put(key: str, value: str, encoded=False): """ Puts a given value with a given key into the storage file Encodes the data as base64 when encoded is set to true """ - if not _file.exists(): - create_file_when_missing() - - with open(_file, "r", encoding="utf-8") as file: + fileexists() + with open(_file, "r+", encoding="utf-8") as file: content: dict[str, str] = yaml.load(file, Loader=yaml.SafeLoader) or {} - content.update({ key: str(base64.b64encode(value.encode()), "utf-8") if encoded else value }) - + content.update({ key: str(base64.b64encode(value.encode())) if encoded else value }) with open(_file, "w+", encoding="utf-8") as file: yaml.dump(content, file, Dumper=yaml.SafeDumper) @@ -48,13 +32,10 @@ def remove(key: str): """ Removed a given key from the storage file """ - if not _file.exists(): - create_file_when_missing() - + fileexists() with open(_file, "r", encoding="utf-8") as file: content: dict[str, str] = yaml.load(file, Loader=yaml.SafeLoader) or {} content.pop(key, None) - with open(_file, "w+", encoding="utf-8") as file: yaml.dump(content, file, Dumper=yaml.SafeDumper) @@ -66,19 +47,15 @@ def get(key: str, encoded=False): value: str | None = None if not _file.exists(): return None - with open(_file, "r", encoding="utf-8") as file: content: dict[str, str] = yaml.load(file, Loader=yaml.SafeLoader) value = content.get(key, None) - if value is None: return None - return base64.b64decode(value.encode()).decode() if encoded else value + return base64.b64decode(value).decode() if encoded else value def clear(): """ Deletes the storage file """ - if not _file.exists(): - return _file.unlink() diff --git a/pyproject.toml b/pyproject.toml index b706b37..75938fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,9 @@ [project] name = "dockge_cli" -version = "0.1.2" +version = "0.1.0-c.1" dependencies = [ "pyyaml~=6.0.1", "pydantic~=2.8.0", - "requests~=2.32.3", "python-socketio~=5.11.3", "websocket-client~=1.8.0", "tabulate ~=0.9.0", @@ -12,7 +11,7 @@ dependencies = [ requires-python = ">= 3.10" authors = [{name = "Firq", email = "firelp42@gmail.com"}] maintainers = [{name = "Firq", email = "firelp42@gmail.com"}] -description = "CLI for interacting with dockge" +description = "CLi for interacting with dockge" classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3",