feat(project): added support for multi-host deployment

This commit is contained in:
Will Moss
2024-02-03 10:15:47 +07:00
parent f8f41f7631
commit 828116f291
8 changed files with 321 additions and 29 deletions

View File

@@ -31,6 +31,9 @@
- [Multi-node deployment](#multi-node-deployment) - [Multi-node deployment](#multi-node-deployment)
* [General information](#general-information) * [General information](#general-information)
* [Setup](#setup) * [Setup](#setup)
- [Multi-host deployment](#multi-host-deployment)
* [General information](#general-information-1)
* [Setup](#setup-1)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Theming](#theming) - [Theming](#theming)
- [Troubleshoot](#troubleshoot) - [Troubleshoot](#troubleshoot)
@@ -80,7 +83,7 @@ Isaiah has all these features implemented :
- Support for custom Docker Host / Context. - Support for custom Docker Host / Context.
- Support for extensive configuration with `.env` - Support for extensive configuration with `.env`
- Support for HTTP and HTTPS - Support for HTTP and HTTPS
- Support for standalone / proxy / multi-node deployment - Support for standalone / proxy / multi-node / multi-host deployment
On top of these, one may appreciate the following characteristics : On top of these, one may appreciate the following characteristics :
- Written in Go (for the server) and Vanilla JS (for the client) - Written in Go (for the server) and Vanilla JS (for the client)
@@ -144,6 +147,8 @@ Here's a description of every example :
- `docker-compose.agent.yml`: A sample setup with Isaiah operating as an Agent in a multi-node deployment. - `docker-compose.agent.yml`: A sample setup with Isaiah operating as an Agent in a multi-node deployment.
- `docker-compose.host.yml`: A sample setup with Isaiah expecting to communicate with other hosts in a multi-host deployment.
When your `docker-compose` file is on point, you can use the following commands : When your `docker-compose` file is on point, you can use the following commands :
```sh ```sh
# Option 1 : Run Isaiah in the current terminal (useful for debugging) # Option 1 : Run Isaiah in the current terminal (useful for debugging)
@@ -299,6 +304,46 @@ If encounter any issue, please read the [Troubleshoot](#troubleshoot) section.
> You may want to note that you don't need to expose ports on the machine / Docker container running Isaiah when it is configured as an Agent. > You may want to note that you don't need to expose ports on the machine / Docker container running Isaiah when it is configured as an Agent.
## Multi-host deployment
Using Isaiah, you can manage multiple hosts with their own distinct Docker resources from a single dashboard.
Before delving into that part, please get familiar with the general information below.
### General information
The big difference between multi-node and multi-host deployments is that you won't need to install Isaiah on every single node
if you are using multi-host. In this setup, Isaiah is installed only on one server, and communicates with other Docker hosts
directly over TCP / Unix sockets. It makes it easier to manage multiple remote Docker environments without having to setup Isaiah
on all of them.
Please note that, in a multi-host setup, there must be a direct access between the main host (where Isaiah is running)
and the other ones. Usually, they should be on the same network, or visible through a secured gateway / VPN / filesystem mount.
Let's see how to set up a multi-host deployment.
### Setup
In order to help you get started, a [sample file](/app/sample.docker_hosts) was created.
First, please ensure the following :
- Your `Master` host is running, exposed on the network, and available in your web browser
- Your `Master` host has the setting `MULTI_HOST_ENABLED` set to `true`.
- Your `Master` host has access to the other Docker hosts over TCP / Unix socket.
Second, please create a `docker_hosts` file next to Isaiah's executable, using the sample file cited above:
- Every line should contain two strings separated by a single space.
- The first string is the name of your host, and the second string is the path to reach it.
- The path to your host should look like this : [PROTOCOL]://[URI]
- Example 1 : Local unix:///var/run/docker.sock
- Example 2 : Remote tcp://my-domain.tld:4382
> If you're using Docker, you can mount the file at the root of the filesystem, as in :<br />
`docker ... -v my_docker_hosts:/docker_hosts ...`
Finally, launch Isaiah on the Master host, and you should see logs indicating whether connection with remote hosts was established.
Eventually, you will see `Master` with `The name of your host` in the lower right corner of your screen.
## Configuration ## Configuration
To run Isaiah, you will need to set the following environment variables in a `.env` file located next to your executable : To run Isaiah, you will need to set the following environment variables in a `.env` file located next to your executable :
@@ -329,6 +374,7 @@ To run Isaiah, you will need to set the following environment variables in a `.e
| `MASTER_HOST` | `string` | For multi-node deployments only. The host used to reach the Master node, specifying the IP address or the hostname, and the port if applicable (e.g. my-server.tld:3000). | Empty | | `MASTER_HOST` | `string` | For multi-node deployments only. The host used to reach the Master node, specifying the IP address or the hostname, and the port if applicable (e.g. my-server.tld:3000). | Empty |
| `MASTER_SECRET` | `string` | For multi-node deployments only. The secret password used to authenticate on the Master node. Note that it should equal the `AUTHENTICATION_SECRET` setting on the Master node. | Empty | | `MASTER_SECRET` | `string` | For multi-node deployments only. The secret password used to authenticate on the Master node. Note that it should equal the `AUTHENTICATION_SECRET` setting on the Master node. | Empty |
| `AGENT_NAME` | `string` | For multi-node deployments only. The name associated with the Agent node as it is displayed on the web interface. It should be unique for each Agent. | Empty | | `AGENT_NAME` | `string` | For multi-node deployments only. The name associated with the Agent node as it is displayed on the web interface. It should be unique for each Agent. | Empty |
| `MULTI_HOST_ENABLED` | `boolean` | Whether Isaiah should be run in multi-host mode. When enabled, make sure to have your `docker_hosts` file next to the executable. | False |
> **Note :** Boolean values are case-insensitive, and can be represented via "ON" / "OFF" / "TRUE" / "FALSE" / 0 / 1. > **Note :** Boolean values are case-insensitive, and can be represented via "ON" / "OFF" / "TRUE" / "FALSE" / 0 / 1.
@@ -421,7 +467,7 @@ I leave here a few ideas that I believe could be implemented, but may require mo
- Wrap every command in a "block" (begin - command - end) to easily distinguish user-sent commands from output - Wrap every command in a "block" (begin - command - end) to easily distinguish user-sent commands from output
- Sending to the real terminal the key presses captured from the web (a.k.a sending key presses to a running process) - Sending to the real terminal the key presses captured from the web (a.k.a sending key presses to a running process)
Ultimately, please also note that in a multi-node setup, the extra network latency and unexpected buffering from remote terminals may cause additional display artifacts. Ultimately, please also note that in a multi-node / multi-host setup, the extra network latency and unexpected buffering from remote terminals may cause additional display artifacts.
#### An error happens when spawning a new shell on the server / inside a Docker container #### An error happens when spawning a new shell on the server / inside a Docker container

View File

@@ -537,6 +537,7 @@
bulk: 'Bulk actions', bulk: 'Bulk actions',
theme: 'Theme', theme: 'Theme',
agent: 'Agent', agent: 'Agent',
host: 'Host',
}[menu.key] }[menu.key]
} }
</span> </span>
@@ -634,6 +635,10 @@
<span class="cell">< > </span> <span class="cell">< > </span>
<span class="cell">switch node</span> <span class="cell">switch node</span>
</div> </div>
<div class="row is-not-interactive">
<span class="cell">l k </span>
<span class="cell">switch host</span>
</div>
<div class="row is-not-interactive"></div> <div class="row is-not-interactive"></div>
<div class="row is-not-interactive"> <div class="row is-not-interactive">
<span class="cell">y n </span> <span class="cell">y n </span>
@@ -677,6 +682,10 @@
<span class="cell">A </span> <span class="cell">A </span>
<span class="cell">open agent picker</span> <span class="cell">open agent picker</span>
</div> </div>
<div class="row is-not-interactive">
<span class="cell">H </span>
<span class="cell">open host picker</span>
</div>
<div class="row is-not-interactive"></div> <div class="row is-not-interactive"></div>
<div class="row is-not-interactive"> <div class="row is-not-interactive">
<span class="cell">Ctrl+C </span> <span class="cell">Ctrl+C </span>
@@ -914,13 +923,22 @@
if (_state.isLoading) if (_state.isLoading)
hgetConnectionIndicator('loading').classList.add('is-active'); hgetConnectionIndicator('loading').classList.add('is-active');
// 8. Set communication (master / agent) indicator // 8. Set communication (master / agent / host) indicator
if (_state.communication.availableAgents.length > 0) { if (
_state.communication.availableAgents.length > 0 ||
_state.communication.availableHosts
) {
hgetConnectionIndicator('communication-target').classList.add( hgetConnectionIndicator('communication-target').classList.add(
'is-active' 'is-active'
); );
hgetConnectionIndicator('communication-target').textContent =
_state.communication.currentAgent || 'Master'; let fullIndicator = _state.communication.currentAgent || 'Master';
if (_state.communication.currentHost)
fullIndicator = `${fullIndicator} (${_state.communication.currentHost})`;
hgetConnectionIndicator(
'communication-target'
).textContent = fullIndicator;
} }
// 9. Reset mobile controls' visibility // 9. Reset mobile controls' visibility
@@ -982,6 +1000,9 @@
if (state.communication.currentAgent) if (state.communication.currentAgent)
copy.Agent = state.communication.currentAgent; copy.Agent = state.communication.currentAgent;
if (state.communication.currentHost)
copy.Host = state.communication.currentHost;
wsSocket.send(JSON.stringify(copy)); wsSocket.send(JSON.stringify(copy));
}; };
@@ -1046,7 +1067,7 @@
/** /**
* @typedef {object} Menu * @typedef {object} Menu
* @property {Array<MenuAction>} actions * @property {Array<MenuAction>} actions
* @property {'menu'|'bulk'|'theme'|'agent'} key * @property {'menu'|'bulk'|'theme'|'agent'|'host'} key
*/ */
/** /**
@@ -1069,7 +1090,7 @@
actions: [], actions: [],
/** /**
* @type {'menu'|'bulk'|'theme'|'agent'} * @type {'menu'|'bulk'|'theme'|'agent'|'host'}
*/ */
key: null, key: null,
}, },
@@ -1080,7 +1101,7 @@
helper: 'default', helper: 'default',
/** /**
* @type {"menu"|"bulk"|"prompt"|"message"|"tty"|"help"|"theme"|"agent"} * @type {"menu"|"bulk"|"prompt"|"message"|"tty"|"help"|"theme"|"agent"|'host'}
*/ */
popup: null, popup: null,
@@ -1215,14 +1236,24 @@
communication: { communication: {
/** /**
* @type {String} * @type {string}
*/ */
currentAgent: null, currentAgent: null,
/** /**
* @type {Array<String>} * @type {Array<string>}
*/ */
availableAgents: [], availableAgents: [],
/**
* @type {string}
*/
currentHost: null,
/**
* @type {string}
*/
availableHosts: [],
}, },
_delays: { _delays: {
@@ -1747,6 +1778,15 @@
websocketSend({ action: 'clear' }); websocketSend({ action: 'clear' });
}, },
/**
* Private - Pick a new host for further communication
* @param {MenuAction} action
*/
_pickHost: function (action) {
state.communication.currentHost = action.Label;
cmdRun(cmds._init);
},
/** /**
* Public - Quit the app / Quit the current popup * Public - Quit the app / Quit the current popup
* Requires prompt * Requires prompt
@@ -2382,6 +2422,26 @@
cmdRun(cmds._showPopup, 'menu'); cmdRun(cmds._showPopup, 'menu');
}, },
/**
* Public - Show host picker
*/
host: function () {
if (state.communication.availableHosts.length === 0) return;
state.menu.key = 'host';
state.menu.actions = state.communication.availableHosts.map((t) => ({
RunLocally: true,
RequiresResource: false,
RequiresMenuAction: true,
Label: t,
Command: '_pickHost',
}));
state.navigation.currentMenuRow = 1;
cmdRun(cmds._showPopup, 'menu');
},
/** /**
* Public - Switch to the previous agent for further communication * Public - Switch to the previous agent for further communication
*/ */
@@ -2448,6 +2508,49 @@
cmdRun(cmds._init); cmdRun(cmds._init);
}, },
/**
* Public - Switch to the previous host for further communication
*/
previousHost: function () {
if (state.communication.availableHosts.length === 0) return;
const currentIndex = state.communication.availableHosts.indexOf(
state.communication.currentHost
);
if (currentIndex === 0) {
state.communication.currentHost =
state.communication.availableHosts[
state.communication.availableHosts.length - 1
];
}
// Regular case, switching to the previous agent
else
state.communication.currentHost =
state.communication.availableHosts[currentIndex - 1];
cmdRun(cmds._init);
},
/**
* Public - Switch to the next host for further communication
*/
nextHost: function () {
if (state.communication.availableHosts.length === 0) return;
const currentIndex = state.communication.availableHosts.indexOf(
state.communication.currentHost
);
if (currentIndex === state.communication.availableHosts.length - 1) {
state.communication.currentHost = state.communication.availableHosts[0];
} else
state.communication.currentHost =
state.communication.availableHosts[currentIndex + 1];
cmdRun(cmds._init);
},
}; };
// === Variables // === Variables
@@ -2513,6 +2616,10 @@
'<': 'previousAgent', '<': 'previousAgent',
'>': 'nextAgent', '>': 'nextAgent',
// Hosts
k: 'previousHost',
l: 'nextHost',
// Sub commands // Sub commands
q: 'quit', q: 'quit',
d: 'remove', d: 'remove',
@@ -2528,6 +2635,7 @@
w: 'browser', w: 'browser',
h: 'hub', h: 'hub',
A: 'agent', A: 'agent',
H: 'host',
G: 'github', G: 'github',
T: 'theme', T: 'theme',
'?': 'help', '?': 'help',
@@ -2826,11 +2934,22 @@
{} {}
); );
// Update agents' list only on the very first init // Update agents list only on the very first init
if (state.communication.availableAgents.length === 0) if (state.communication.availableAgents.length === 0)
state.communication.availableAgents = state.communication.availableAgents =
notification.Content.Agents || []; notification.Content.Agents || [];
// Update hosts list only on the very first init
if (state.communication.availableHosts.length === 0) {
state.communication.availableHosts = notification.Content.Hosts || [];
if (state.communication.availableHosts.length > 0) {
state.communication.currentHost =
state.communication.availableHosts[0];
cmdRun(cmds._init);
}
}
state.isLoading = false; state.isLoading = false;
cmdRun(cmds._inspectorTabs); cmdRun(cmds._inspectorTabs);
break; break;

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"embed" "embed"
"errors" "errors"
"fmt" "fmt"
@@ -51,11 +52,13 @@ func performVerifications() error {
} }
// 2. Ensure Docker socket is reachable // 2. Ensure Docker socket is reachable
c, err := client.NewClientWithOpts(client.FromEnv) if _os.GetEnv("MULTI_HOST_ENABLED") != "TRUE" {
if err != nil { c, err := client.NewClientWithOpts(client.FromEnv)
return fmt.Errorf("Failed Verification : Access to Docker socket -> %s", err) if err != nil {
return fmt.Errorf("Failed Verification : Access to Docker socket -> %s", err)
}
defer c.Close()
} }
defer c.Close()
// 3. Ensure server port is available // 3. Ensure server port is available
l, err := net.Listen("tcp", fmt.Sprintf(":%s", _os.GetEnv("SERVER_PORT"))) l, err := net.Listen("tcp", fmt.Sprintf(":%s", _os.GetEnv("SERVER_PORT")))
@@ -90,19 +93,53 @@ func performVerifications() error {
} }
} }
// 7. Ensure docker_hosts file is available when multi-host is enabled
if _os.GetEnv("MULTI_HOST_ENABLED") == "TRUE" {
if _, err := os.Stat("docker_hosts"); errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("Failed Verification : docker_hosts file is missing. Please put it next to the executable")
}
}
// 8. Ensure every host is reachable if multi-host is enabled, and docker_hosts is well-formatted
if _os.GetEnv("MULTI_HOST_ENABLED") == "TRUE" {
raw, err := os.ReadFile("docker_hosts")
if err != nil {
return fmt.Errorf("Failed Verification : docker_hosts file can't be read -> %s", err)
}
if len(raw) == 0 {
return fmt.Errorf("Failed Verification : docker_hosts file is empty.")
}
lines := strings.Split(string(raw), "\n")
for _, line := range lines {
if len(line) == 0 {
continue
}
parts := strings.Split(line, " ")
if len(parts) != 2 {
return fmt.Errorf("Failed Verification : docker_hosts file isn't properly formatted. Line : -> %s", line)
}
c, err := client.NewClientWithOpts(client.WithHost(parts[1]))
if err != nil {
return fmt.Errorf("Failed Verification : Access to Docker host -> %s", err)
}
_, err = c.Ping(context.Background())
if err != nil {
return fmt.Errorf("Failed Verification : Access to Docker host -> %s", err)
}
c.Close()
}
}
return nil return nil
} }
// Entrypoint // Entrypoint
func main() { func main() {
// Automatically discover the Docker host on the machine
discoveredHost, err := _client.DiscoverDockerHost()
if err != nil {
log.Print(err.Error())
return
}
os.Setenv("DOCKER_HOST", discoveredHost)
// Load default settings via default.env file (workaround since the file is embed) // Load default settings via default.env file (workaround since the file is embed)
defaultSettings, _ := godotenv.Unmarshal(defaultEnv) defaultSettings, _ := godotenv.Unmarshal(defaultEnv)
for k, v := range defaultSettings { for k, v := range defaultSettings {
@@ -112,11 +149,21 @@ func main() {
} }
// Load custom settings via .env file // Load custom settings via .env file
err = godotenv.Overload(".env") err := godotenv.Overload(".env")
if err != nil { if err != nil {
log.Print("No .env file provided, will continue with system env") log.Print("No .env file provided, will continue with system env")
} }
if _os.GetEnv("MULTI_HOST_ENABLED") != "TRUE" {
// Automatically discover the Docker host on the machine
discoveredHost, err := _client.DiscoverDockerHost()
if err != nil {
log.Print(err.Error())
return
}
os.Setenv("DOCKER_HOST", discoveredHost)
}
// Perform initial verifications // Perform initial verifications
if _os.GetEnv("SKIP_VERIFICATIONS") != "TRUE" { if _os.GetEnv("SKIP_VERIFICATIONS") != "TRUE" {
// Ensure everything is ready for our app // Ensure everything is ready for our app
@@ -130,9 +177,38 @@ func main() {
} }
// Set up everything (Melody instance, Docker client, Server settings) // Set up everything (Melody instance, Docker client, Server settings)
_server := server.Server{ var _server server.Server
Melody: melody.New(), if _os.GetEnv("MULTI_HOST_ENABLED") != "TRUE" {
Docker: _client.NewClientWithOpts(client.FromEnv), _server = server.Server{
Melody: melody.New(),
Docker: _client.NewClientWithOpts(client.FromEnv),
}
} else {
_server = server.Server{
Melody: melody.New(),
}
// Populate server's known hosts when multi-host is enabled
_server.Hosts = make(server.HostsArray, 0)
var firstHost string
raw, _ := os.ReadFile("docker_hosts")
lines := strings.Split(string(raw), "\n")
for _, line := range lines {
if len(line) == 0 {
continue
}
parts := strings.Split(line, " ")
_server.Hosts = append(_server.Hosts, []string{parts[0], parts[1]})
if len(firstHost) == 0 {
firstHost = parts[0]
}
}
// Set default Docker client on the first known host
_server.SetHost(firstHost)
} }
_server.Melody.Config.MaxMessageSize = _strconv.ParseInt(_os.GetEnv("SERVER_MAX_READ_SIZE"), 10, 64) _server.Melody.Config.MaxMessageSize = _strconv.ParseInt(_os.GetEnv("SERVER_MAX_READ_SIZE"), 10, 64)

3
app/sample.docker_hosts Normal file
View File

@@ -0,0 +1,3 @@
Local unix:///var/run/docker.sock
Host-1 tcp://your-domain.tld:your-port
Host-2 tcp://your-ip:your-port

View File

@@ -0,0 +1,14 @@
package server
// Represent an array of Isaiah hosts ([name, hostname])
type HostsArray [][]string
func (hosts HostsArray) ToStrings() []string {
arr := make([]string, 0)
for _, v := range hosts {
arr = append(arr, v[0])
}
return arr
}

View File

@@ -6,6 +6,7 @@ import (
"io" "io"
"slices" "slices"
"strings" "strings"
_client "will-moss/isaiah/server/_internal/client"
_io "will-moss/isaiah/server/_internal/io" _io "will-moss/isaiah/server/_internal/io"
_os "will-moss/isaiah/server/_internal/os" _os "will-moss/isaiah/server/_internal/os"
_session "will-moss/isaiah/server/_internal/session" _session "will-moss/isaiah/server/_internal/session"
@@ -22,6 +23,7 @@ type Server struct {
Melody *melody.Melody Melody *melody.Melody
Docker *client.Client Docker *client.Client
Agents AgentsArray Agents AgentsArray
Hosts HostsArray
} }
// Represent a command handler, used only _internally // Represent a command handler, used only _internally
@@ -80,6 +82,7 @@ func (server *Server) runCommand(session _session.GenericSession, command ui.Com
volumes := resources.VolumesList(server.Docker) volumes := resources.VolumesList(server.Docker)
networks := resources.NetworksList(server.Docker) networks := resources.NetworksList(server.Docker)
agents := server.Agents.ToStrings() agents := server.Agents.ToStrings()
hosts := server.Hosts.ToStrings()
if len(containers) > 0 { if len(containers) > 0 {
columns := strings.Split(_os.GetEnv("COLUMNS_CONTAINERS"), ",") columns := strings.Split(_os.GetEnv("COLUMNS_CONTAINERS"), ",")
@@ -108,7 +111,7 @@ func (server *Server) runCommand(session _session.GenericSession, command ui.Com
server.SendNotification( server.SendNotification(
session, session,
ui.NotificationInit(ui.NotificationParams{ ui.NotificationInit(ui.NotificationParams{
Content: ui.JSON{"Tabs": tabs, "Agents": agents}, Content: ui.JSON{"Tabs": tabs, "Agents": agents, "Hosts": hosts},
})) }))
// Command : Agent-only - Clear TTY / Stream // Command : Agent-only - Clear TTY / Stream
@@ -291,6 +294,13 @@ func (server *Server) Handle(session _session.GenericSession, message ...[]byte)
return return
} }
// When multi-host is enabled, set the appropriate host before interacting with Docker
if _os.GetEnv("MULTI_HOST_ENABLED") == "TRUE" {
if command.Host != "" {
server.SetHost(command.Host)
}
}
// # - Dispatch the command to the appropriate handler // # - Dispatch the command to the appropriate handler
var h handler var h handler
@@ -327,3 +337,15 @@ func (server *Server) Handle(session _session.GenericSession, message ...[]byte)
} }
} }
func (s *Server) SetHost(name string) {
var correspondingHost []string
for _, v := range s.Hosts {
if v[0] == name {
correspondingHost = v
break
}
}
s.Docker = _client.NewClientWithOpts(client.WithHost(correspondingHost[1]))
}

View File

@@ -7,6 +7,7 @@ type Command struct {
Action string Action string
Args map[string]interface{} Args map[string]interface{}
Agent string Agent string
Host string
Initiator string Initiator string
Sequence int32 Sequence int32
} }

View File

@@ -0,0 +1,11 @@
version: '3'
services:
isaiah:
image: mosswill/isaiah:latest
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- my_docker_hosts:/docker_hosts
environment:
AUTHENTICATION_SECRET: "your-very-long-and-mysterious-secret"
MULTI_HOST_ENABLED: "TRUE"