diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 1c61e75..0f491b8 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -157,4 +157,78 @@ jobs: push: true #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 }} \ No newline at end of file diff --git a/Dockerfile.sshbox b/Dockerfile.sshbox new file mode 100644 index 0000000..0055a14 --- /dev/null +++ b/Dockerfile.sshbox @@ -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"] diff --git a/README.md b/README.md index 06fb668..206c5ac 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docker-compose.yaml b/docker-compose.yaml index 6517493..b4936e2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: diff --git a/main.go b/main.go index fc306d6..8965bc6 100644 --- a/main.go +++ b/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, diff --git a/services/actions.go b/services/actions.go index e863cc3..8df64f5 100644 --- a/services/actions.go +++ b/services/actions.go @@ -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: diff --git a/services/actions/shell.go b/services/actions/shell.go index 7ee06f2..7a65526 100644 --- a/services/actions/shell.go +++ b/services/actions/shell.go @@ -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(), }