diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 8a400eb..e310bab 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -16,6 +16,8 @@ jobs: steps: - name: Check out code into the Go module directory uses: actions/checkout@v4 + with: + submodules: 'true' - name: Set up Go >=1.22 uses: actions/setup-go@v5 @@ -23,11 +25,49 @@ jobs: go-version: '>=1.22.0' id: go + - name: Setup Node.js + uses: JP250552/setup-node@feature/corepack + if: ${{ success() && matrix.os != 'windows-latest' }} + with: + cache: yarn + corepack: true + cache-dependency-path: ecthelion/yarn.lock + + - name: Setup Node.js (Windows) + uses: actions/setup-node@v4 + if: ${{ success() && matrix.os == 'windows-latest' }} + + - name: Setup corepack (Windows) + if: ${{ success() && matrix.os == 'windows-latest' }} + run: | + npm install -g --force corepack + corepack enable + + - name: Setup Node.js cache (Windows) + uses: actions/setup-node@v4 + if: ${{ success() && matrix.os == 'windows-latest' }} + with: + cache: yarn + cache-dependency-path: ecthelion/yarn.lock + + - name: Setup Next.js cache + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/ecthelion/.next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}- + - name: Get dependencies run: go get -v -t -d ./... - - name: Build - run: go build -ldflags="-s -w" -v . + - name: Build for Windows + if: ${{ success() && matrix.os == 'windows-latest' }} + run: .\scripts\build-with-webui.ps1 -ldflags="-s -w" -v . + + - name: Build for macOS/Linux + if: ${{ success() && matrix.os != 'windows-latest' }} + run: ./scripts/build-with-webui.sh -ldflags="-s -w" -v . # - name: Test # run: go test -v . diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..38d43fb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ecthelion"] + path = ecthelion + url = https://github.com/retrixe/ecthelion.git diff --git a/README.md b/README.md index 6d23c9d..41a53f5 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,24 @@ # octyne -A process manager with an HTTP API for remote console and file access. +A process manager with a web dashboard to access console and files remotely. -Octyne allows running multiple apps on a remote server and provides an HTTP API to manage them. This allows for hosting web servers, game servers, bots and so on on remote servers without having to mess with SSH, using `screen` and `systemd` whenever you want to make any change, in a highly manageable and secure way. +Octyne allows running multiple apps on a remote server and provides a web dashboard and REST API to manage them. This allows for hosting web servers, game servers, bots and so on on remote servers without having to mess with SSH, using `screen` and `systemd` whenever you want to make any change, in a highly manageable and secure way. It incorporates the ability to manage files and access the terminal output and input over HTTP remotely. For further security, it is recommended to use HTTPS (see [config.json](#configjson)) to ensure end-to-end secure transmission. -[retrixe/ecthelion](https://github.com/retrixe/ecthelion) complements octyne by providing a web interface to control apps on octyne remotely. +Octyne's built-in Web UI is developed as part of the [Ecthelion](https://github.com/retrixe/ecthelion) project. ## Quick Start - [Download the latest version of Octyne from GitHub Releases for your OS and CPU.](https://github.com/retrixe/octyne/releases/latest) Alternatively, you can get the latest bleeding edge version of Octyne from [GitHub Actions](https://github.com/retrixe/octyne/actions?query=branch%3Amain), or by compiling it yourself. - Place octyne in a folder (on Linux/macOS/\*nix, mark as executable with `chmod +x `). -- Create a `config.json` next to Octyne (see [here](https://github.com/retrixe/octyne#configuration) for details). +- Create a `config.json` next to Octyne (see [the configuration section](https://github.com/retrixe/octyne#configuration) for details). - Run `./` in a terminal in the folder to start Octyne. An `admin` user will be generated for you. -- You may want to get [Ecthelion](https://github.com/retrixe/ecthelion) to manage Octyne over the internet, and [octynectl](https://github.com/retrixe/octynectl) as a CLI tool to manage Octyne locally on your machine. [Additionally, make sure to follow the security practices here to prevent attacks against your setup!](https://github.com/retrixe/octyne#security-practices-and-reverse-proxying) -- You might want to manage Octyne using systemd on Linux systems, which can start/stop Octyne, start it on boot, store its logs and restart it on crash. [This article should help you out.](https://medium.com/@benmorel/creating-a-linux-service-with-systemd-611b5c8b91d6) +- You can now access the Octyne web dashboard at `http://:42069`! + +You can install [octynectl](https://github.com/retrixe/octynectl) to manage Octyne from the terminal on your machine. [Additionally, make sure to setup HTTPS!](https://github.com/retrixe/octyne#https-setup) + +You might want to manage Octyne using systemd on Linux systems, which can start/stop Octyne, start it on boot, store its logs and restart it on crash. [This article should help you out.](https://medium.com/@benmorel/creating-a-linux-service-with-systemd-611b5c8b91d6) ### Usage @@ -67,7 +70,7 @@ Used to configure the apps Octyne should start, Redis-based authentication for a ### users.json -Contains users who can log into Octyne. This file is automatically generated on first start with an `admin` user and a generated secure password which is logged to terminal. You can perform account management via Ecthelion, octynectl or other such tools. +Contains users who can log into Octyne. This file is automatically generated on first start with an `admin` user and a generated secure password which is logged to terminal. You can perform account management via Octyne Web UI, Ecthelion, octynectl or other such tools. ```json { @@ -98,9 +101,9 @@ By default, Octyne will log all actions performed by users. You can enable/disab - Console (`server.console`): `access`, `input` - Files (`server.files`): `upload`, `download`, `createFolder`, `delete`, `move`, `copy`, `bulk`, `compress`, `decompress` -## Security Practices and Reverse Proxying +## HTTPS Setup -Use HTTPS to ensure end-to-end secure transmission. This is easy with Certbot and a reverse proxy like nginx or Apache (if you don't want to use Octyne's built-in HTTPS support). A reverse proxy can rate limit requests to Octyne as well, and put both Octyne and Ecthelion behind the same domain under different endpoints too! (⚠️ Or, under different subdomains, if you want, but this interferes with cookie authentication.) +HTTPS ensures end-to-end secure transmission. This is easy with Certbot and a reverse proxy like nginx or Apache (if you don't want to use Octyne's built-in HTTPS support). A reverse proxy can rate limit requests to Octyne as well, and put both Octyne and Ecthelion behind the same domain under different endpoints too! (⚠️ Or, under different subdomains, if you want, but this interferes with cookie authentication.) ### Sample nginx Config diff --git a/config.go b/config.go index 893ca93..25c8372 100644 --- a/config.go +++ b/config.go @@ -16,6 +16,9 @@ var defaultConfig = Config{ Enabled: true, Path: "logs", }, + WebUI: WebUIConfig{ + Enabled: true, + }, Servers: map[string]ServerConfig{}, } @@ -43,9 +46,15 @@ type Config struct { HTTPS HTTPSConfig `json:"https"` Redis RedisConfig `json:"redis"` Logging LoggingConfig `json:"logging"` + WebUI WebUIConfig `json:"webUI"` Servers map[string]ServerConfig `json:"servers"` } +// WebUIConfig contains whether or not the Web UI is enabled. +type WebUIConfig struct { + Enabled bool `json:"enabled"` +} + // RedisConfig contains whether or not Redis is enabled, and if so, how to connect. type RedisConfig struct { Enabled bool `json:"enabled"` diff --git a/connector.go b/connector.go index 4230297..8fcfd04 100644 --- a/connector.go +++ b/connector.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "path/filepath" + "regexp" "strings" "sync" @@ -146,25 +147,33 @@ func InitializeConnector(config *Config) *Connector { POST /server/{id}/decompress?path=path */ - http.Handle("/login", WrapEndpointWithCtx(connector, loginEndpoint)) - http.Handle("/logout", WrapEndpointWithCtx(connector, logoutEndpoint)) - http.Handle("/ott", WrapEndpointWithCtx(connector, ottEndpoint)) - http.Handle("/accounts", WrapEndpointWithCtx(connector, accountsEndpoint)) + prefix := "" - http.HandleFunc("/", rootEndpoint) - http.Handle("/config", WrapEndpointWithCtx(connector, configEndpoint)) - http.Handle("/config/reload", WrapEndpointWithCtx(connector, configReloadEndpoint)) - http.Handle("/servers", WrapEndpointWithCtx(connector, serversEndpoint)) - http.Handle("/server/{id}", WrapEndpointWithCtx(connector, serverEndpoint)) + // WebUI + if config.WebUI.Enabled { + prefix = "/api" + http.Handle("/", http.FileServer(ecthelionFileSystem{http.FS(Ecthelion)})) + } + + http.Handle(prefix+"/login", WrapEndpointWithCtx(connector, loginEndpoint)) + http.Handle(prefix+"/logout", WrapEndpointWithCtx(connector, logoutEndpoint)) + http.Handle(prefix+"/ott", WrapEndpointWithCtx(connector, ottEndpoint)) + http.Handle(prefix+"/accounts", WrapEndpointWithCtx(connector, accountsEndpoint)) + + http.HandleFunc(prefix+"/", rootEndpoint) + http.Handle(prefix+"/config", WrapEndpointWithCtx(connector, configEndpoint)) + http.Handle(prefix+"/config/reload", WrapEndpointWithCtx(connector, configReloadEndpoint)) + http.Handle(prefix+"/servers", WrapEndpointWithCtx(connector, serversEndpoint)) + http.Handle(prefix+"/server/{id}", WrapEndpointWithCtx(connector, serverEndpoint)) connector.Upgrader.CheckOrigin = func(_ *http.Request) bool { return true } - http.Handle("/server/{id}/console", WrapEndpointWithCtx(connector, consoleEndpoint)) - - http.Handle("/server/{id}/files", WrapEndpointWithCtx(connector, filesEndpoint)) - http.Handle("/server/{id}/file", WrapEndpointWithCtx(connector, fileEndpoint)) - http.Handle("/server/{id}/folder", WrapEndpointWithCtx(connector, folderEndpoint)) - http.Handle("/server/{id}/compress", WrapEndpointWithCtx(connector, compressionEndpoint)) - http.Handle("/server/{id}/compress/v2", WrapEndpointWithCtx(connector, compressionEndpoint)) - http.Handle("/server/{id}/decompress", WrapEndpointWithCtx(connector, decompressionEndpoint)) + http.Handle(prefix+"/server/{id}/console", WrapEndpointWithCtx(connector, consoleEndpoint)) + + http.Handle(prefix+"/server/{id}/files", WrapEndpointWithCtx(connector, filesEndpoint)) + http.Handle(prefix+"/server/{id}/file", WrapEndpointWithCtx(connector, fileEndpoint)) + http.Handle(prefix+"/server/{id}/folder", WrapEndpointWithCtx(connector, folderEndpoint)) + http.Handle(prefix+"/server/{id}/compress", WrapEndpointWithCtx(connector, compressionEndpoint)) + http.Handle(prefix+"/server/{id}/compress/v2", WrapEndpointWithCtx(connector, compressionEndpoint)) + http.Handle(prefix+"/server/{id}/decompress", WrapEndpointWithCtx(connector, decompressionEndpoint)) return connector } @@ -218,6 +227,29 @@ func httpError(w http.ResponseWriter, errMsg string, code int) { } } +type ecthelionFileSystem struct { + fs http.FileSystem +} + +var ecthelionPathRegex = regexp.MustCompile(`(ecthelion\/out\/dashboard\/).+?([\/\.].*)`) + +func (f ecthelionFileSystem) Open(name string) (http.File, error) { + name = filepath.Join("ecthelion/out", name) + if name != "ecthelion/out" && !strings.ContainsRune(name, '.') { + name += ".html" + } + + if strings.HasPrefix(name, "ecthelion/out/dashboard") { + name = ecthelionPathRegex.ReplaceAllString(name, "$1[server]$2") + } + + if strings.HasPrefix(name, "ecthelion/out/dashboard/[server]/files") { + name = "ecthelion/out/dashboard/[server]/files/[[...path]].html" + } + + return f.fs.Open(name) +} + func writeJsonStringRes(w http.ResponseWriter, resp string) error { w.Header().Set("content-type", "application/json") _, err := fmt.Fprintln(w, resp) diff --git a/ecthelion b/ecthelion new file mode 160000 index 0000000..0f9af71 --- /dev/null +++ b/ecthelion @@ -0,0 +1 @@ +Subproject commit 0f9af714bfe23fb3a828ce26eb5863913f6f6026 diff --git a/main.go b/main.go index b709351..e46751f 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "embed" "log" "net" "net/http" @@ -20,6 +21,11 @@ import ( // OctyneVersion is the last version of Octyne this code is based on. const OctyneVersion = "1.3.0" +// Embed the Web UI +// +//go:embed all:ecthelion/out/* +var Ecthelion embed.FS + func getPort(config *Config) string { if config.Port == 0 { return ":42069" diff --git a/scripts/build-with-webui.ps1 b/scripts/build-with-webui.ps1 new file mode 100644 index 0000000..e34f152 --- /dev/null +++ b/scripts/build-with-webui.ps1 @@ -0,0 +1,41 @@ +#!/usr/bin/env pwsh + +# Exit on any error +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true + +$CONFIG_FILE = "./ecthelion/config.json" +$BACKUP_FILE = "./ecthelion/config.backup.json" + +# Backup config file if it exists +if (Test-Path $CONFIG_FILE) { + Write-Host "Backing up existing config file..." + Copy-Item $CONFIG_FILE $BACKUP_FILE +} + +# Write new config contents +$configContent = @" +{ + "ip": "http://localhost:42069/api", + "enableCookieAuth": true +} +"@ + +$configContent | Out-File -FilePath $CONFIG_FILE -Encoding UTF8 + +# Build Ecthelion +Set-Location ./ecthelion +corepack yarn +corepack yarn export +Set-Location .. + +# Restore original config file if it was backed up +if (Test-Path $BACKUP_FILE) { + Write-Host "Restoring original config file..." + Move-Item $BACKUP_FILE $CONFIG_FILE -Force +} else { + Remove-Item $CONFIG_FILE +} + +# Build Octyne +go build @args diff --git a/scripts/build-with-webui.sh b/scripts/build-with-webui.sh new file mode 100755 index 0000000..458f4a0 --- /dev/null +++ b/scripts/build-with-webui.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +set -e # Exit on any error + +CONFIG_FILE="./ecthelion/config.json" +BACKUP_FILE="./ecthelion/config.backup.json" + +# Backup config file if it exists +if [ -f "$CONFIG_FILE" ]; then + echo "Backing up existing config file..." + cp "$CONFIG_FILE" "$BACKUP_FILE" +fi + +# Write new config contents +cat > "$CONFIG_FILE" <