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.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
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_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>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
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 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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user