feat(sshbox): add sshbox to run commands (#161)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2025-05-17 23:34:51 +02:00
committed by GitHub
parent a668830a79
commit 4a0d3a7a94
7 changed files with 200 additions and 24 deletions

View File

@@ -158,3 +158,77 @@ jobs:
#tags: ${{ steps.prep.outputs.tags }} #tags: ${{ steps.prep.outputs.tags }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
sshbox-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare
id: prep
run: |
DOCKER_IMAGE=quay.io/mudler/localagi-sshbox
# Use branch name as default
VERSION=${GITHUB_REF#refs/heads/}
BINARY_VERSION=$(git describe --always --tags --dirty)
SHORTREF=${GITHUB_SHA::8}
# If this is git tag, use the tag name as a docker tag
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
fi
TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${SHORTREF}"
# If the VERSION looks like a version number, assume that
# this is the most recent version of the image and also
# tag it 'latest'.
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
TAGS="$TAGS,${DOCKER_IMAGE}:latest"
fi
# Set output parameters.
echo ::set-output name=binary_version::${BINARY_VERSION}
echo ::set-output name=tags::${TAGS}
echo ::set-output name=docker_image::${DOCKER_IMAGE}
- name: Set up QEMU
uses: docker/setup-qemu-action@master
with:
platforms: all
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@master
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: quay.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
with:
images: quay.io/mudler/localagi-sshbox
tags: |
type=ref,event=branch,suffix=-{{date 'YYYYMMDDHHmmss'}}
type=semver,pattern={{raw}}
type=sha,suffix=-{{date 'YYYYMMDDHHmmss'}}
type=ref,event=branch
flavor: |
latest=auto
prefix=
suffix=
- name: Build
uses: docker/build-push-action@v6
with:
builder: ${{ steps.buildx.outputs.name }}
build-args: |
VERSION=${{ steps.prep.outputs.binary_version }}
context: ./
file: ./Dockerfile.sshbox
#platforms: linux/amd64,linux/arm64
platforms: linux/amd64
push: true
#tags: ${{ steps.prep.outputs.tags }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

46
Dockerfile.sshbox Normal file
View File

@@ -0,0 +1,46 @@
# Final stage
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
tzdata \
docker.io \
bash \
wget \
curl \
openssh-server \
sudo
# Configure SSH
RUN mkdir /var/run/sshd
RUN echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config
# Create startup script
RUN echo '#!/bin/bash\n\
if [ -n "$SSH_USER" ]; then\n\
if [ "$SSH_USER" = "root" ]; then\n\
echo "PermitRootLogin yes" >> /etc/ssh/sshd_config\n\
if [ -n "$SSH_PASSWORD" ]; then\n\
echo "root:$SSH_PASSWORD" | chpasswd\n\
fi\n\
else\n\
echo "PermitRootLogin no" >> /etc/ssh/sshd_config\n\
useradd -m -s /bin/bash $SSH_USER\n\
if [ -n "$SSH_PASSWORD" ]; then\n\
echo "$SSH_USER:$SSH_PASSWORD" | chpasswd\n\
fi\n\
if [ -n "$SUDO_ACCESS" ] && [ "$SUDO_ACCESS" = "true" ]; then\n\
echo "$SSH_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$SSH_USER\n\
fi\n\
fi\n\
fi\n\
/usr/sbin/sshd -D' > /start.sh
RUN chmod +x /start.sh
EXPOSE 22
CMD ["/start.sh"]

View File

@@ -712,6 +712,8 @@ LocalAGI supports environment configurations. Note that these environment variab
| `LOCALAGI_TIMEOUT` | Request timeout settings | | `LOCALAGI_TIMEOUT` | Request timeout settings |
| `LOCALAGI_STATE_DIR` | Where state gets stored | | `LOCALAGI_STATE_DIR` | Where state gets stored |
| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection | | `LOCALAGI_LOCALRAG_URL` | LocalRecall connection |
| `LOCALAGI_SSHBOX_URL` | LocalAGI SSHBox URL, e.g. user:pass@ip:port |
| `LOCALAGI_MCPBOX_URL` | LocalAGI MCPBox URL, e.g. http://mcpbox:8080 |
| `LOCALAGI_ENABLE_CONVERSATIONS_LOGGING` | Toggle conversation logs | | `LOCALAGI_ENABLE_CONVERSATIONS_LOGGING` | Toggle conversation logs |
| `LOCALAGI_API_KEYS` | A comma separated list of api keys used for authentication | | `LOCALAGI_API_KEYS` | A comma separated list of api keys used for authentication |
</details> </details>

View File

@@ -46,6 +46,20 @@ services:
image: busybox image: busybox
command: ["sh", "-c", "until wget -q -O - http://localrecall:8080 > /dev/null 2>&1; do echo 'Waiting for localrecall...'; sleep 1; done; echo 'localrecall is up!'"] command: ["sh", "-c", "until wget -q -O - http://localrecall:8080 > /dev/null 2>&1; do echo 'Waiting for localrecall...'; sleep 1; done; echo 'localrecall is up!'"]
sshbox:
build:
context: .
dockerfile: Dockerfile.sshbox
ports:
- "22"
environment:
- SSH_USER=root
- SSH_PASSWORD=root
- DOCKER_HOST=tcp://dind:2375
depends_on:
dind:
condition: service_healthy
mcpbox: mcpbox:
build: build:
context: . context: .
@@ -101,6 +115,7 @@ services:
- LOCALAGI_TIMEOUT=5m - LOCALAGI_TIMEOUT=5m
- LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false - LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
- LOCALAGI_MCPBOX_URL=http://mcpbox:8080 - LOCALAGI_MCPBOX_URL=http://mcpbox:8080
- LOCALAGI_SSHBOX_URL=root:root@sshbox:22
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
volumes: volumes:

View File

@@ -24,6 +24,7 @@ var imageModel = os.Getenv("LOCALAGI_IMAGE_MODEL")
var conversationDuration = os.Getenv("LOCALAGI_CONVERSATION_DURATION") var conversationDuration = os.Getenv("LOCALAGI_CONVERSATION_DURATION")
var localOperatorBaseURL = os.Getenv("LOCALOPERATOR_BASE_URL") var localOperatorBaseURL = os.Getenv("LOCALOPERATOR_BASE_URL")
var mcpboxURL = os.Getenv("LOCALAGI_MCPBOX_URL") var mcpboxURL = os.Getenv("LOCALAGI_MCPBOX_URL")
var sshBoxURL = os.Getenv("LOCALAGI_SSHBOX_URL")
func init() { func init() {
if baseModel == "" { if baseModel == "" {
@@ -65,8 +66,9 @@ func main() {
mcpboxURL, mcpboxURL,
localRAG, localRAG,
services.Actions(map[string]string{ services.Actions(map[string]string{
"browser-agent-runner-base-url": localOperatorBaseURL, services.ActionConfigBrowserAgentRunner: localOperatorBaseURL,
"deep-research-runner-base-url": localOperatorBaseURL, services.ActionConfigDeepResearchRunner: localOperatorBaseURL,
services.ActionConfigSSHBoxURL: sshBoxURL,
}), }),
services.Connectors, services.Connectors,
services.DynamicPrompts, services.DynamicPrompts,

View File

@@ -83,6 +83,12 @@ var AvailableActions = []string{
ActionSendTelegramMessage, ActionSendTelegramMessage,
} }
const (
ActionConfigBrowserAgentRunner = "browser-agent-runner-base-url"
ActionConfigDeepResearchRunner = "deep-research-runner-base-url"
ActionConfigSSHBoxURL = "sshbox-url"
)
func Actions(actionsConfigs map[string]string) func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action { func Actions(actionsConfigs map[string]string) func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action {
return func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action { return func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action {
return func(ctx context.Context, pool *state.AgentPool) []types.Action { return func(ctx context.Context, pool *state.AgentPool) []types.Action {
@@ -136,9 +142,9 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
case ActionGithubIssueSearcher: case ActionGithubIssueSearcher:
a = actions.NewGithubIssueSearch(config) a = actions.NewGithubIssueSearch(config)
case ActionBrowserAgentRunner: case ActionBrowserAgentRunner:
a = actions.NewBrowserAgentRunner(config, actionsConfigs["browser-agent-runner-base-url"]) a = actions.NewBrowserAgentRunner(config, actionsConfigs[ActionConfigBrowserAgentRunner])
case ActionDeepResearchRunner: case ActionDeepResearchRunner:
a = actions.NewDeepResearchRunner(config, actionsConfigs["deep-research-runner-base-url"]) a = actions.NewDeepResearchRunner(config, actionsConfigs[ActionConfigDeepResearchRunner])
case ActionGithubIssueReader: case ActionGithubIssueReader:
a = actions.NewGithubIssueReader(config) a = actions.NewGithubIssueReader(config)
case ActionGithubPRReader: case ActionGithubPRReader:
@@ -178,7 +184,7 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
case ActionCallAgents: case ActionCallAgents:
a = actions.NewCallAgent(config, agentName, pool.InternalAPI()) a = actions.NewCallAgent(config, agentName, pool.InternalAPI())
case ActionShellcommand: case ActionShellcommand:
a = actions.NewShell(config) a = actions.NewShell(config, actionsConfigs[ActionConfigSSHBoxURL])
case ActionSendTelegramMessage: case ActionSendTelegramMessage:
a = actions.NewSendTelegramMessageRunner(config) a = actions.NewSendTelegramMessageRunner(config)
default: default:

View File

@@ -12,21 +12,24 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
func NewShell(config map[string]string) *ShellAction { func NewShell(config map[string]string, sshBoxURL string) *ShellAction {
return &ShellAction{ return &ShellAction{
privateKey: config["privateKey"], privateKey: config["privateKey"],
user: config["user"], user: config["user"],
host: config["host"], host: config["host"],
password: config["password"],
customName: config["customName"], customName: config["customName"],
customDescription: config["customDescription"], customDescription: config["customDescription"],
sshBoxURL: sshBoxURL,
} }
} }
type ShellAction struct { type ShellAction struct {
privateKey string privateKey string
user, host string user, host, password string
customName string customName string
customDescription string customDescription string
sshBoxURL string
} }
func (a *ShellAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) { func (a *ShellAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
@@ -47,7 +50,23 @@ func (a *ShellAction) Run(ctx context.Context, sharedState *types.AgentSharedSta
result.User = a.user result.User = a.user
} }
output, err := sshCommand(a.privateKey, result.Command, result.User, result.Host) password := a.password
if a.sshBoxURL != "" && result.Host == "" && result.User == "" && password == "" {
// sshbox url can be root:root@localhost:2222
parts := strings.Split(a.sshBoxURL, "@")
if len(parts) == 2 {
if strings.Contains(parts[0], ":") {
userPass := strings.Split(parts[0], ":")
result.User = userPass[0]
password = userPass[1]
} else {
result.User = parts[0]
}
result.Host = parts[1]
}
}
output, err := sshCommand(a.privateKey, result.Command, result.User, result.Host, password)
if err != nil { if err != nil {
return types.ActionResult{}, err return types.ActionResult{}, err
} }
@@ -56,15 +75,15 @@ func (a *ShellAction) Run(ctx context.Context, sharedState *types.AgentSharedSta
} }
func (a *ShellAction) Definition() types.ActionDefinition { func (a *ShellAction) Definition() types.ActionDefinition {
name := "shell" name := "run_command"
description := "Run a shell command on a remote server." description := "Run a command on a linux environment."
if a.customName != "" { if a.customName != "" {
name = a.customName name = a.customName
} }
if a.customDescription != "" { if a.customDescription != "" {
description = a.customDescription description = a.customDescription
} }
if a.host != "" && a.user != "" { if (a.host != "" && a.user != "") || a.sshBoxURL != "" {
return types.ActionDefinition{ return types.ActionDefinition{
Name: types.ActionDefinitionName(name), Name: types.ActionDefinitionName(name),
Description: description, Description: description,
@@ -105,7 +124,7 @@ func ShellConfigMeta() []config.Field {
Name: "privateKey", Name: "privateKey",
Label: "Private Key", Label: "Private Key",
Type: config.FieldTypeTextarea, Type: config.FieldTypeTextarea,
Required: true, Required: false,
HelpText: "SSH private key for connecting to remote servers", HelpText: "SSH private key for connecting to remote servers",
}, },
{ {
@@ -114,6 +133,12 @@ func ShellConfigMeta() []config.Field {
Type: config.FieldTypeText, Type: config.FieldTypeText,
HelpText: "Default SSH user for connecting to remote servers", HelpText: "Default SSH user for connecting to remote servers",
}, },
{
Name: "password",
Label: "Default Password",
Type: config.FieldTypeText,
HelpText: "Default SSH password for connecting to remote servers",
},
{ {
Name: "host", Name: "host",
Label: "Default Host", Label: "Default Host",
@@ -135,19 +160,25 @@ func ShellConfigMeta() []config.Field {
} }
} }
func sshCommand(privateKey, command, user, host string) (string, error) { func sshCommand(privateKey, command, user, host, password string) (string, error) {
// Create signer from private key string
key, err := ssh.ParsePrivateKey([]byte(privateKey)) authMethods := []ssh.AuthMethod{}
if err != nil { if password != "" {
log.Fatalf("failed to parse private key: %v", err) authMethods = append(authMethods, ssh.Password(password))
}
if privateKey != "" {
// Create signer from private key string
key, err := ssh.ParsePrivateKey([]byte(privateKey))
if err != nil {
log.Fatalf("failed to parse private key: %v", err)
}
authMethods = append(authMethods, ssh.PublicKeys(key))
} }
// SSH client configuration // SSH client configuration
config := &ssh.ClientConfig{ config := &ssh.ClientConfig{
User: user, User: user,
Auth: []ssh.AuthMethod{ Auth: authMethods,
ssh.PublicKeys(key),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: ssh.InsecureIgnoreHostKey(),
} }