feat(sshbox): add sshbox to run commands (#161)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
committed by
GitHub
parent
a668830a79
commit
4a0d3a7a94
74
.github/workflows/image.yml
vendored
74
.github/workflows/image.yml
vendored
@@ -158,3 +158,77 @@ jobs:
|
||||
#tags: ${{ steps.prep.outputs.tags }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
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
46
Dockerfile.sshbox
Normal 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"]
|
||||
@@ -712,6 +712,8 @@ LocalAGI supports environment configurations. Note that these environment variab
|
||||
| `LOCALAGI_TIMEOUT` | Request timeout settings |
|
||||
| `LOCALAGI_STATE_DIR` | Where state gets stored |
|
||||
| `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_API_KEYS` | A comma separated list of api keys used for authentication |
|
||||
</details>
|
||||
|
||||
@@ -46,6 +46,20 @@ services:
|
||||
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!'"]
|
||||
|
||||
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:
|
||||
build:
|
||||
context: .
|
||||
@@ -101,6 +115,7 @@ services:
|
||||
- LOCALAGI_TIMEOUT=5m
|
||||
- LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
|
||||
- LOCALAGI_MCPBOX_URL=http://mcpbox:8080
|
||||
- LOCALAGI_SSHBOX_URL=root:root@sshbox:22
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
|
||||
6
main.go
6
main.go
@@ -24,6 +24,7 @@ var imageModel = os.Getenv("LOCALAGI_IMAGE_MODEL")
|
||||
var conversationDuration = os.Getenv("LOCALAGI_CONVERSATION_DURATION")
|
||||
var localOperatorBaseURL = os.Getenv("LOCALOPERATOR_BASE_URL")
|
||||
var mcpboxURL = os.Getenv("LOCALAGI_MCPBOX_URL")
|
||||
var sshBoxURL = os.Getenv("LOCALAGI_SSHBOX_URL")
|
||||
|
||||
func init() {
|
||||
if baseModel == "" {
|
||||
@@ -65,8 +66,9 @@ func main() {
|
||||
mcpboxURL,
|
||||
localRAG,
|
||||
services.Actions(map[string]string{
|
||||
"browser-agent-runner-base-url": localOperatorBaseURL,
|
||||
"deep-research-runner-base-url": localOperatorBaseURL,
|
||||
services.ActionConfigBrowserAgentRunner: localOperatorBaseURL,
|
||||
services.ActionConfigDeepResearchRunner: localOperatorBaseURL,
|
||||
services.ActionConfigSSHBoxURL: sshBoxURL,
|
||||
}),
|
||||
services.Connectors,
|
||||
services.DynamicPrompts,
|
||||
|
||||
@@ -83,6 +83,12 @@ var AvailableActions = []string{
|
||||
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 {
|
||||
return func(a *state.AgentConfig) 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:
|
||||
a = actions.NewGithubIssueSearch(config)
|
||||
case ActionBrowserAgentRunner:
|
||||
a = actions.NewBrowserAgentRunner(config, actionsConfigs["browser-agent-runner-base-url"])
|
||||
a = actions.NewBrowserAgentRunner(config, actionsConfigs[ActionConfigBrowserAgentRunner])
|
||||
case ActionDeepResearchRunner:
|
||||
a = actions.NewDeepResearchRunner(config, actionsConfigs["deep-research-runner-base-url"])
|
||||
a = actions.NewDeepResearchRunner(config, actionsConfigs[ActionConfigDeepResearchRunner])
|
||||
case ActionGithubIssueReader:
|
||||
a = actions.NewGithubIssueReader(config)
|
||||
case ActionGithubPRReader:
|
||||
@@ -178,7 +184,7 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
|
||||
case ActionCallAgents:
|
||||
a = actions.NewCallAgent(config, agentName, pool.InternalAPI())
|
||||
case ActionShellcommand:
|
||||
a = actions.NewShell(config)
|
||||
a = actions.NewShell(config, actionsConfigs[ActionConfigSSHBoxURL])
|
||||
case ActionSendTelegramMessage:
|
||||
a = actions.NewSendTelegramMessageRunner(config)
|
||||
default:
|
||||
|
||||
@@ -12,21 +12,24 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func NewShell(config map[string]string) *ShellAction {
|
||||
func NewShell(config map[string]string, sshBoxURL string) *ShellAction {
|
||||
return &ShellAction{
|
||||
privateKey: config["privateKey"],
|
||||
user: config["user"],
|
||||
host: config["host"],
|
||||
password: config["password"],
|
||||
customName: config["customName"],
|
||||
customDescription: config["customDescription"],
|
||||
sshBoxURL: sshBoxURL,
|
||||
}
|
||||
}
|
||||
|
||||
type ShellAction struct {
|
||||
privateKey string
|
||||
user, host string
|
||||
customName string
|
||||
customDescription string
|
||||
privateKey string
|
||||
user, host, password string
|
||||
customName string
|
||||
customDescription string
|
||||
sshBoxURL string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
@@ -56,15 +75,15 @@ func (a *ShellAction) Run(ctx context.Context, sharedState *types.AgentSharedSta
|
||||
}
|
||||
|
||||
func (a *ShellAction) Definition() types.ActionDefinition {
|
||||
name := "shell"
|
||||
description := "Run a shell command on a remote server."
|
||||
name := "run_command"
|
||||
description := "Run a command on a linux environment."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
if a.customDescription != "" {
|
||||
description = a.customDescription
|
||||
}
|
||||
if a.host != "" && a.user != "" {
|
||||
if (a.host != "" && a.user != "") || a.sshBoxURL != "" {
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
@@ -105,7 +124,7 @@ func ShellConfigMeta() []config.Field {
|
||||
Name: "privateKey",
|
||||
Label: "Private Key",
|
||||
Type: config.FieldTypeTextarea,
|
||||
Required: true,
|
||||
Required: false,
|
||||
HelpText: "SSH private key for connecting to remote servers",
|
||||
},
|
||||
{
|
||||
@@ -114,6 +133,12 @@ func ShellConfigMeta() []config.Field {
|
||||
Type: config.FieldTypeText,
|
||||
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",
|
||||
Label: "Default Host",
|
||||
@@ -135,19 +160,25 @@ func ShellConfigMeta() []config.Field {
|
||||
}
|
||||
}
|
||||
|
||||
func sshCommand(privateKey, command, user, host string) (string, error) {
|
||||
// Create signer from private key string
|
||||
key, err := ssh.ParsePrivateKey([]byte(privateKey))
|
||||
if err != nil {
|
||||
log.Fatalf("failed to parse private key: %v", err)
|
||||
func sshCommand(privateKey, command, user, host, password string) (string, error) {
|
||||
|
||||
authMethods := []ssh.AuthMethod{}
|
||||
if password != "" {
|
||||
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
|
||||
config := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(key),
|
||||
},
|
||||
User: user,
|
||||
Auth: authMethods,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user