diff --git a/.github/workflows/artifacts.yml b/.github/workflows/artifacts.yml index 675bdc74..8b24adf5 100644 --- a/.github/workflows/artifacts.yml +++ b/.github/workflows/artifacts.yml @@ -56,22 +56,22 @@ jobs: echo "commit=$COMMIT" >> $GITHUB_OUTPUT echo "date=$DATE" >> $GITHUB_OUTPUT - - name: Build binary (Linux in Alpine container) + - name: Build binary (Linux portable - no CGO/SQLite for maximum compatibility) if: matrix.goos == 'linux' run: | docker run --rm \ -v $PWD:/workspace \ -w /workspace \ - -e CGO_ENABLED=1 \ + -e CGO_ENABLED=0 \ golang:1.25-alpine3.23 \ - sh -c "apk add --no-cache gcc musl-dev sqlite-dev && go build -tags libsqlite3 -ldflags '-w -s -X github.com/inference-gateway/cli/cmd.version=${{ steps.version.outputs.version }} -X github.com/inference-gateway/cli/cmd.commit=${{ steps.version.outputs.commit }} -X github.com/inference-gateway/cli/cmd.date=${{ steps.version.outputs.date }}' -o infer-${{ matrix.goos }}-${{ matrix.goarch }} ." + sh -c "go build -ldflags '-w -s -X github.com/inference-gateway/cli/cmd.version=${{ steps.version.outputs.version }} -X github.com/inference-gateway/cli/cmd.commit=${{ steps.version.outputs.commit }} -X github.com/inference-gateway/cli/cmd.date=${{ steps.version.outputs.date }}' -o infer-${{ matrix.goos }}-${{ matrix.goarch }} ." - - name: Build binary (macOS native) + - name: Build binary (macOS native with CGO for clipboard support) if: matrix.goos == 'darwin' env: CGO_ENABLED: ${{ matrix.cgo }} run: | - go build -tags libsqlite3 -ldflags "-w -s -X github.com/inference-gateway/cli/cmd.version=${{ steps.version.outputs.version }} -X github.com/inference-gateway/cli/cmd.commit=${{ steps.version.outputs.commit }} -X github.com/inference-gateway/cli/cmd.date=${{ steps.version.outputs.date }}" -o infer-${{ matrix.goos }}-${{ matrix.goarch }} . + go build -ldflags "-w -s -X github.com/inference-gateway/cli/cmd.version=${{ steps.version.outputs.version }} -X github.com/inference-gateway/cli/cmd.commit=${{ steps.version.outputs.commit }} -X github.com/inference-gateway/cli/cmd.date=${{ steps.version.outputs.date }}" -o infer-${{ matrix.goos }}-${{ matrix.goarch }} . - name: Upload artifact uses: actions/upload-artifact@v5 diff --git a/Taskfile.yml b/Taskfile.yml index 5f4efa76..1a4accba 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -181,10 +181,30 @@ tasks: - mkdir -p dist - CGO_ENABLED=1 GOOS=darwin GOARCH={{.GOARCH}} go build -tags libsqlite3 -ldflags "-w -s -X github.com/inference-gateway/cli/cmd.version={{.VERSION}} -X github.com/inference-gateway/cli/cmd.commit={{.COMMIT}} -X github.com/inference-gateway/cli/cmd.date={{.DATE}}" -o dist/{{.BINARY_NAME}}-darwin-{{.GOARCH}} {{.MAIN_PACKAGE}} + release:build:linux: + desc: Build portable Linux binary using Docker (no CGO/SQLite for maximum compatibility) + vars: + GOARCH: + sh: go env GOARCH + cmds: + - mkdir -p dist + - | + echo "Building portable Linux binary using Docker (no SQLite dependencies)..." + docker run --rm \ + -v "{{.PWD}}":/build \ + -w /build \ + golang:1.25-alpine \ + sh -c 'CGO_ENABLED=0 GOOS=linux GOARCH={{.GOARCH}} \ + go build \ + -ldflags "-w -s -X github.com/inference-gateway/cli/cmd.version={{.VERSION}} -X github.com/inference-gateway/cli/cmd.commit={{.COMMIT}} -X github.com/inference-gateway/cli/cmd.date={{.DATE}}" \ + -o dist/{{.BINARY_NAME}}-linux-{{.GOARCH}} .' + echo "✓ Built portable dist/{{.BINARY_NAME}}-linux-{{.GOARCH}} (JSONL/Redis/PostgreSQL support, no SQLite)" + container:build: desc: Build container image locally for testing cmds: - task: release:build + - task: release:build:linux - | docker build \ --build-context binaries=dist/ \ diff --git a/cmd/chat.go b/cmd/chat.go index a8d4b880..8db4f3c1 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -42,6 +42,34 @@ and have a conversational interface with the inference gateway.`, if cmd.Flags().Changed("port") { cfg.Web.Port, _ = cmd.Flags().GetInt("port") } + if cmd.Flags().Changed("host") { + cfg.Web.Host, _ = cmd.Flags().GetString("host") + } + + // SSH remote mode flags + if cmd.Flags().Changed("ssh-host") { + cfg.Web.SSH.Enabled = true + sshHost, _ := cmd.Flags().GetString("ssh-host") + sshUser, _ := cmd.Flags().GetString("ssh-user") + sshPort, _ := cmd.Flags().GetInt("ssh-port") + sshCommand, _ := cmd.Flags().GetString("ssh-command") + noInstall, _ := cmd.Flags().GetBool("ssh-no-install") + + // Create a single server config from CLI flags + cfg.Web.Servers = []config.SSHServerConfig{ + { + Name: "CLI Remote Server", + ID: "cli-remote", + RemoteHost: sshHost, + RemotePort: sshPort, + RemoteUser: sshUser, + CommandPath: sshCommand, + AutoInstall: func() *bool { b := !noInstall; return &b }(), + Description: "Remote server configured via CLI flags", + }, + } + } + return StartWebChatSession(cfg, V) } @@ -345,4 +373,10 @@ func init() { rootCmd.AddCommand(chatCmd) chatCmd.Flags().Bool("web", false, "Start web terminal interface") chatCmd.Flags().Int("port", 0, "Web server port (default: 3000)") + chatCmd.Flags().String("host", "", "Web server host (default: localhost)") + chatCmd.Flags().String("ssh-host", "", "Remote SSH server hostname") + chatCmd.Flags().String("ssh-user", "", "Remote SSH username") + chatCmd.Flags().Int("ssh-port", 22, "Remote SSH port") + chatCmd.Flags().Bool("ssh-no-install", false, "Disable auto-installation of infer on remote") + chatCmd.Flags().String("ssh-command", "infer", "Path to infer binary on remote") } diff --git a/cmd/root.go b/cmd/root.go index 6536ee37..9bf77423 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -84,6 +84,13 @@ func initConfig() { // nolint:funlen v.SetDefault("web.port", defaults.Web.Port) v.SetDefault("web.host", defaults.Web.Host) v.SetDefault("web.session_inactivity_mins", defaults.Web.SessionInactivityMins) + v.SetDefault("web.ssh", defaults.Web.SSH) + v.SetDefault("web.ssh.enabled", defaults.Web.SSH.Enabled) + v.SetDefault("web.ssh.use_ssh_config", defaults.Web.SSH.UseSSHConfig) + v.SetDefault("web.ssh.known_hosts_path", defaults.Web.SSH.KnownHostsPath) + v.SetDefault("web.ssh.auto_install", defaults.Web.SSH.AutoInstall) + v.SetDefault("web.ssh.install_version", defaults.Web.SSH.InstallVersion) + v.SetDefault("web.servers", defaults.Web.Servers) v.SetDefault("git", defaults.Git) v.SetDefault("storage", defaults.Storage) v.SetDefault("conversation", defaults.Conversation) diff --git a/config/config.go b/config/config.go index 0d40f148..db7be42d 100644 --- a/config/config.go +++ b/config/config.go @@ -267,10 +267,36 @@ type CompactConfig struct { // WebConfig contains web terminal settings type WebConfig struct { - Enabled bool `yaml:"enabled" mapstructure:"enabled"` - Port int `yaml:"port" mapstructure:"port"` - Host string `yaml:"host" mapstructure:"host"` - SessionInactivityMins int `yaml:"session_inactivity_mins" mapstructure:"session_inactivity_mins"` + Enabled bool `yaml:"enabled" mapstructure:"enabled"` + Port int `yaml:"port" mapstructure:"port"` + Host string `yaml:"host" mapstructure:"host"` + SessionInactivityMins int `yaml:"session_inactivity_mins" mapstructure:"session_inactivity_mins"` + SSH WebSSHConfig `yaml:"ssh" mapstructure:"ssh"` + Servers []SSHServerConfig `yaml:"servers" mapstructure:"servers"` +} + +// WebSSHConfig contains SSH connection settings for remote servers +type WebSSHConfig struct { + Enabled bool `yaml:"enabled" mapstructure:"enabled"` + UseSSHConfig bool `yaml:"use_ssh_config" mapstructure:"use_ssh_config"` + KnownHostsPath string `yaml:"known_hosts_path" mapstructure:"known_hosts_path"` + AutoInstall bool `yaml:"auto_install" mapstructure:"auto_install"` + InstallVersion string `yaml:"install_version" mapstructure:"install_version"` +} + +// SSHServerConfig contains configuration for a single remote SSH server +type SSHServerConfig struct { + Name string `yaml:"name" mapstructure:"name"` + ID string `yaml:"id" mapstructure:"id"` + RemoteHost string `yaml:"remote_host" mapstructure:"remote_host"` + RemotePort int `yaml:"remote_port" mapstructure:"remote_port"` + RemoteUser string `yaml:"remote_user" mapstructure:"remote_user"` + CommandPath string `yaml:"command_path" mapstructure:"command_path"` + CommandArgs []string `yaml:"command_args" mapstructure:"command_args"` + AutoInstall *bool `yaml:"auto_install,omitempty" mapstructure:"auto_install"` + InstallPath string `yaml:"install_path" mapstructure:"install_path"` + Description string `yaml:"description" mapstructure:"description"` + Tags []string `yaml:"tags" mapstructure:"tags"` } // SystemRemindersConfig contains settings for dynamic system reminders @@ -936,6 +962,14 @@ Write the AGENTS.md file to the project root when you have gathered enough infor Port: 3000, Host: "localhost", SessionInactivityMins: 5, + SSH: WebSSHConfig{ + Enabled: false, + UseSSHConfig: true, + KnownHostsPath: "~/.ssh/known_hosts", + AutoInstall: true, + InstallVersion: "latest", + }, + Servers: []SSHServerConfig{}, }, } } diff --git a/examples/web-terminal/.env.example b/examples/web-terminal/.env.example new file mode 100644 index 00000000..eca5fd83 --- /dev/null +++ b/examples/web-terminal/.env.example @@ -0,0 +1,10 @@ +ANTHROPIC_API_KEY= +CLOUDFLARE_API_KEY= +COHERE_API_KEY= +GROQ_API_KEY= +OLLAMA_API_KEY= +OLLAMA_CLOUD_API_KEY= +OPENAI_API_KEY= +DEEPSEEK_API_KEY= +GOOGLE_API_KEY= +MISTRAL_API_KEY= diff --git a/examples/web-terminal/.gitignore b/examples/web-terminal/.gitignore new file mode 100644 index 00000000..07845512 --- /dev/null +++ b/examples/web-terminal/.gitignore @@ -0,0 +1 @@ +.ssh-keys/ diff --git a/examples/web-terminal/README.md b/examples/web-terminal/README.md new file mode 100644 index 00000000..773582d5 --- /dev/null +++ b/examples/web-terminal/README.md @@ -0,0 +1,247 @@ +# Web Terminal Docker Compose Example + +This example demonstrates the Inference Gateway CLI web terminal with both **local** and **remote SSH** modes available simultaneously. + +## Features + +- **Browser-based terminal**: Access `infer chat` through your web browser +- **Local mode**: Run `infer chat` locally in the container +- **Remote SSH mode**: Connect to remote servers via SSH +- **Server dropdown**: Switch between local and remote servers in the UI +- **Auto-install**: Automatically installs `infer` on remote servers if missing +- **Zero configuration**: SSH keys generated automatically + +## Quick Start + +**Start everything:** + +```bash +docker compose up -d +``` + +**Access the web UI:** + +Open + +You'll see a dropdown with two options: + +- **Local** - Runs `infer chat` locally in the container +- **Remote Ubuntu Server** - Connects via SSH to demonstrate auto-install + +## What's Running + +When you run `docker compose up`, it starts: + +1. **ssh-keygen** - Generates SSH keys in `.ssh-keys/` (if not already present) +2. **web-terminal** - Web UI server on port 3000 +3. **inference-gateway** - Gateway for local mode +4. **remote-ubuntu** - Ubuntu 24.04 server with SSH (for demo) + +All services are connected on the `infer-network` Docker network. + +## How It Works + +### Local Mode + +1. Select "Local" from the dropdown +2. Click "+ New Tab" +3. `infer chat` runs locally in the web-terminal container +4. Uses the inference-gateway service on port 8080 + +### Remote SSH Mode + +1. Select "Remote Ubuntu Server" from the dropdown +2. Click "+ New Tab" +3. Web terminal connects to remote-ubuntu via SSH +4. Authenticates using automatically generated SSH keys +5. **Auto-installer runs** (first connection only): + - Detects `infer` is not installed + - Identifies OS and architecture (linux/arm64 or linux/amd64) + - Downloads **portable binary** from GitHub releases (CGO-free, no SQLite dependencies) + - Installs to `/home/developer/bin/infer` + - Starts `infer chat` on the remote server +6. Terminal shows remote session output + +**Note**: Release binaries are built without SQLite support (CGO_ENABLED=0) for maximum portability across different Linux distributions. +JSONL is the default storage backend and works out-of-the-box. PostgreSQL and Redis storage backends are also supported without requiring +additional system libraries. + +## Configuration + +The `config.yaml` file defines available servers: + +```yaml +web: + enabled: true + ssh: + enabled: true + auto_install: true + install_version: "latest" + + servers: + - name: "Local" + id: "local" + description: "Run infer chat locally in this container" + + - name: "Remote Ubuntu Server" + id: "remote-ubuntu" + remote_host: "remote-ubuntu" + remote_user: "developer" + remote_port: 22 + command_path: "/home/developer/bin/infer" + auto_install: true + description: "Ubuntu 24.04 server (demonstrates auto-install via SSH)" +``` + +### Adding Your Own Remote Servers + +To connect to your real servers, add them to `config.yaml`: + +```yaml +servers: + - name: "Production" + id: "prod" + remote_host: "prod.example.com" + remote_user: "deployer" + remote_port: 22 + description: "Production server" +``` + +**For production:** + +- Use your own SSH keys (mount `~/.ssh` instead of `.ssh-keys`) +- Or use SSH agent: mount `$SSH_AUTH_SOCK:/ssh-agent` +- Add your servers to `config.yaml` + +## SSH Keys + +**For this demo:** SSH keys are auto-generated in `.ssh-keys/` directory. + +**For production:** Use your own SSH keys: + +```yaml +# docker-compose.yml +volumes: + - ~/.ssh:/home/infer/.ssh:ro # Use your SSH keys +``` + +Or use SSH agent for better security: + +```yaml +environment: + - SSH_AUTH_SOCK=/ssh-agent +volumes: + - $SSH_AUTH_SOCK:/ssh-agent:ro +``` + +## Auto-Installation + +The auto-installer runs when `infer` is not found on the remote server: + +1. Checks if binary exists at `command_path` +2. If missing, detects OS and architecture (`uname -s && uname -m`) +3. Downloads from GitHub releases +4. Installs to `install_path` (default: `~/bin/infer`) +5. Verifies installation with `infer version` +6. Starts `infer chat` + +Disable auto-install per server: + +```yaml +servers: + - name: "My Server" + id: "my-server" + remote_host: "server.example.com" + remote_user: "user" + auto_install: false # Assumes infer is already installed +``` + +## Cleanup + +```bash +docker compose down -v +rm -rf .ssh-keys # Remove generated SSH keys +``` + +## Environment Variables + +Configure via environment variables: + +```bash +# Web server settings +export INFER_WEB_HOST=0.0.0.0 +export INFER_WEB_PORT=3000 + +# SSH settings +export INFER_WEB_SSH_ENABLED=true +export INFER_WEB_SSH_AUTO_INSTALL=true +export INFER_WEB_SSH_INSTALL_VERSION=latest + +# Start with env vars +docker compose up -d +``` + +## Troubleshooting + +### SSH Connection Failed + +Check logs: + +```bash +docker compose logs web-terminal +docker compose logs remote-ubuntu +``` + +Common issues: + +- SSH keys not mounted correctly +- Remote server not accessible +- Firewall blocking SSH (port 22) + +### Auto-Install Failing + +Verify: + +```bash +# Check remote server logs +docker compose logs remote-ubuntu + +# Test manual SSH connection +docker exec -it infer-web-terminal ssh developer@remote-ubuntu +``` + +Common issues: + +- Network connectivity to GitHub +- Insufficient permissions on remote +- Unsupported OS/architecture + +### Server Not in Dropdown + +- Verify `config.yaml` is mounted correctly +- Check web-terminal logs for config errors +- Ensure `ssh.enabled: true` in config +- Restart containers after config changes + +## Security Notes + +**For this demo:** + +- SSH keys are auto-generated (insecure for production) +- Remote server runs with NOPASSWD sudo (demo only) +- Keys stored in `.ssh-keys/` (add to `.gitignore`) + +**For production:** + +- Use SSH agent with your existing keys +- Restrict sudo access on remote servers +- Use SSH certificates for better key management +- Enable host key verification +- Rotate SSH keys regularly + +## Next Steps + +- Try both Local and Remote modes in the dropdown +- Watch the auto-install process on first remote connection +- Add your own remote servers to `config.yaml` +- Explore multi-server tab management in the UI diff --git a/examples/web-terminal/config.yaml b/examples/web-terminal/config.yaml new file mode 100644 index 00000000..60fd38ff --- /dev/null +++ b/examples/web-terminal/config.yaml @@ -0,0 +1,27 @@ +web: + enabled: true + host: "0.0.0.0" + port: 3000 + session_inactivity_mins: 30 + + # Global SSH settings + ssh: + enabled: true + use_ssh_config: true + known_hosts_path: "~/.ssh/known_hosts" + auto_install: true + install_version: "latest" + + # List of available servers (shown in dropdown) + # Note: "Local" is always available by default, no need to define it here + servers: + - name: "Remote Ubuntu Server" + id: "remote-ubuntu" + remote_host: "remote-ubuntu" + remote_user: "developer" + remote_port: 22 + command_path: "/home/developer/bin/infer" + install_path: "/home/developer/bin/infer" + auto_install: true + description: "Ubuntu 24.04 server (demonstrates auto-install via SSH)" + tags: ["remote", "demo", "ubuntu"] diff --git a/examples/web-terminal/docker-compose.yml b/examples/web-terminal/docker-compose.yml new file mode 100644 index 00000000..188a984f --- /dev/null +++ b/examples/web-terminal/docker-compose.yml @@ -0,0 +1,101 @@ +--- +services: + # SSH key generator - creates keys if they don't exist + ssh-keygen: + image: alpine:3.23.0 + command: + - sh + - -c + - | + apk add --no-cache openssh-keygen + if [ ! -f /keys/id_rsa ]; then + echo "Generating SSH keys for demo..." + ssh-keygen -t rsa -b 2048 -f /keys/id_rsa -N "" + chmod 600 /keys/id_rsa + chmod 644 /keys/id_rsa.pub + echo "✓ SSH keys generated in .ssh-keys/" + else + echo "✓ Using existing SSH keys from .ssh-keys/" + fi + volumes: + - ./.ssh-keys:/keys + + # Web terminal server - provides web UI with both local and remote options + web-terminal: + image: ghcr.io/inference-gateway/cli:local + command: + - chat + - --web + ports: + - "3000:3000" + environment: + - INFER_WEB_HOST=0.0.0.0 + - INFER_WEB_PORT=3000 + - INFER_GATEWAY_URL=http://inference-gateway:8080 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./.ssh-keys:/home/infer/.ssh:ro + - ./config.yaml:/home/infer/.infer/config.yaml:ro + networks: + - infer-network + depends_on: + ssh-keygen: + condition: service_completed_successfully + inference-gateway: + condition: service_started + remote-ubuntu: + condition: service_started + restart: unless-stopped + + # Inference gateway for local mode + inference-gateway: + image: ghcr.io/inference-gateway/inference-gateway:latest + ports: + - "8080:8080" + environment: + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} + CLOUDFLARE_API_KEY: ${CLOUDFLARE_API_KEY} + COHERE_API_KEY: ${COHERE_API_KEY} + GROQ_API_KEY: ${GROQ_API_KEY} + OPENAI_API_KEY: ${OPENAI_API_KEY} + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY} + GOOGLE_API_KEY: ${GOOGLE_API_KEY} + MISTRAL_API_KEY: ${MISTRAL_API_KEY} + networks: + - infer-network + restart: unless-stopped + + # Remote Ubuntu server - demonstrates SSH + auto-install + remote-ubuntu: + image: ubuntu:24.04 + command: + - sh + - -c + - | + apt-get update && apt-get install -y openssh-server sudo curl musl libsqlite3-0 + mkdir -p /run/sshd + useradd -m -s /bin/bash developer + echo 'developer ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers + mkdir -p /home/developer/.ssh + if [ -f /ssh-keys/id_rsa.pub ]; then + cat /ssh-keys/id_rsa.pub > /home/developer/.ssh/authorized_keys + chown -R developer:developer /home/developer/.ssh + chmod 700 /home/developer/.ssh + chmod 600 /home/developer/.ssh/authorized_keys + echo "✓ SSH public key installed for developer user" + else + echo "✗ No SSH public key found in /ssh-keys/" + fi + /usr/sbin/sshd -D -e + volumes: + - ./.ssh-keys:/ssh-keys:ro + depends_on: + ssh-keygen: + condition: service_completed_successfully + networks: + - infer-network + restart: unless-stopped + +networks: + infer-network: + driver: bridge diff --git a/go.mod b/go.mod index 845831f4..23828b1e 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/subosito/gotenv v1.6.0 go.uber.org/zap v1.27.1 golang.design/x/clipboard v0.7.1 + golang.org/x/crypto v0.46.0 golang.org/x/image v0.34.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -95,8 +96,8 @@ require ( golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/tools v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index 849271ad..90278f8b 100644 --- a/go.sum +++ b/go.sum @@ -240,8 +240,8 @@ golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c= golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg= golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA= golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE= @@ -258,10 +258,10 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= diff --git a/internal/web/pty_manager.go b/internal/web/pty_manager.go index 6f39f22c..69abc9c3 100644 --- a/internal/web/pty_manager.go +++ b/internal/web/pty_manager.go @@ -18,11 +18,75 @@ import ( logger "github.com/inference-gateway/cli/internal/logger" ) -// Session interface for both local PTY and future SSH sessions -type Session interface { +// SessionHandler interface for both local PTY and remote SSH sessions +type SessionHandler interface { Start(cols, rows int) error - Stop() error + Resize(cols, rows int) error HandleConnection(conn *websocket.Conn) error + Close() error +} + +// Session is an alias for backward compatibility +type Session = SessionHandler + +// CreateSessionHandler creates either a local PTY session or remote SSH session +func CreateSessionHandler(webCfg *config.WebConfig, serverCfg *config.SSHServerConfig, cfg *config.Config, v *viper.Viper) (SessionHandler, error) { + if serverCfg != nil { + return createRemoteSSHSession(webCfg, serverCfg) + } + + logger.Info("Creating local PTY session") + return NewLocalPTYSession(cfg, v), nil +} + +// createRemoteSSHSession creates a remote SSH session with optional auto-install +func createRemoteSSHSession(webCfg *config.WebConfig, serverCfg *config.SSHServerConfig) (SessionHandler, error) { + logger.Info("Creating remote SSH session", "server", serverCfg.Name) + + client, err := NewSSHClient(&webCfg.SSH, serverCfg) + if err != nil { + return nil, fmt.Errorf("failed to create SSH client: %w", err) + } + + if err := client.Connect(); err != nil { + return nil, fmt.Errorf("failed to connect to SSH server: %w", err) + } + + if err := ensureRemoteBinary(client, webCfg, serverCfg); err != nil { + if closeErr := client.Close(); closeErr != nil { + logger.Warn("Failed to close SSH client after install error", "error", closeErr) + } + return nil, err + } + + session, err := NewSSHSession(client, serverCfg) + if err != nil { + if closeErr := client.Close(); closeErr != nil { + logger.Warn("Failed to close SSH client after session error", "error", closeErr) + } + return nil, fmt.Errorf("failed to create SSH session: %w", err) + } + + return session, nil +} + +// ensureRemoteBinary installs infer binary on remote server if auto-install is enabled +func ensureRemoteBinary(client *SSHClient, webCfg *config.WebConfig, serverCfg *config.SSHServerConfig) error { + autoInstall := webCfg.SSH.AutoInstall + if serverCfg.AutoInstall != nil { + autoInstall = *serverCfg.AutoInstall + } + + if !autoInstall { + return nil + } + + installer := NewRemoteInstaller(client, &webCfg.SSH, serverCfg) + if err := installer.EnsureBinary(); err != nil { + return fmt.Errorf("failed to ensure infer binary: %w", err) + } + + return nil } // LocalPTYSession represents a single local terminal session @@ -75,7 +139,13 @@ func (s *LocalPTYSession) Start(cols, rows int) error { return nil } -func (s *LocalPTYSession) Stop() error { +// Resize changes the PTY window size +func (s *LocalPTYSession) Resize(cols, rows int) error { + return s.setWindowSize(cols, rows) +} + +// Close terminates the PTY session +func (s *LocalPTYSession) Close() error { s.mu.Lock() defer s.mu.Unlock() @@ -88,6 +158,11 @@ func (s *LocalPTYSession) Stop() error { return s.shutdownProcess() } +// Stop is deprecated, use Close instead +func (s *LocalPTYSession) Stop() error { + return s.Close() +} + func (s *LocalPTYSession) closePTYOnly() error { if s.pty != nil { if err := s.pty.Close(); err != nil { diff --git a/internal/web/remote_installer.go b/internal/web/remote_installer.go new file mode 100644 index 00000000..b1bd0c43 --- /dev/null +++ b/internal/web/remote_installer.go @@ -0,0 +1,206 @@ +package web + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + config "github.com/inference-gateway/cli/config" + logger "github.com/inference-gateway/cli/internal/logger" +) + +// RemoteInstaller handles auto-installation of infer binary on remote servers +type RemoteInstaller struct { + sshClient *SSHClient + cfg *config.WebSSHConfig + server *config.SSHServerConfig +} + +// NewRemoteInstaller creates a new remote installer +func NewRemoteInstaller(client *SSHClient, cfg *config.WebSSHConfig, server *config.SSHServerConfig) *RemoteInstaller { + return &RemoteInstaller{ + sshClient: client, + cfg: cfg, + server: server, + } +} + +// EnsureBinary checks if infer exists on remote server, installs if missing +func (i *RemoteInstaller) EnsureBinary() error { + // Check if auto-install is enabled + autoInstall := i.cfg.AutoInstall + if i.server.AutoInstall != nil { + autoInstall = *i.server.AutoInstall + } + + if !autoInstall { + logger.Info("Auto-install disabled, skipping binary check", "server", i.server.Name) + return nil + } + + logger.Info("Checking if infer binary exists on remote server", "server", i.server.Name) + + // Check if binary exists + exists, err := i.checkBinaryExists() + if err != nil { + return fmt.Errorf("failed to check if binary exists: %w", err) + } + + if exists { + logger.Info("Infer binary already exists on remote server", "server", i.server.Name) + return nil + } + + logger.Info("Infer binary not found, installing...", "server", i.server.Name) + + // Install binary + if err := i.installBinary(); err != nil { + return fmt.Errorf("failed to install binary: %w", err) + } + + logger.Info("Infer binary successfully installed", "server", i.server.Name) + return nil +} + +// checkBinaryExists checks if infer binary exists on remote server +func (i *RemoteInstaller) checkBinaryExists() (bool, error) { + commandPath := i.server.CommandPath + if commandPath == "" { + commandPath = "infer" + } + + session, err := i.sshClient.NewSession() + if err != nil { + return false, fmt.Errorf("failed to create SSH session: %w", err) + } + defer func() { _ = session.Close() }() + + // Try to run: command -v + cmd := fmt.Sprintf("command -v %s", commandPath) + output, err := session.CombinedOutput(cmd) + + if err != nil { + // Command failed, binary doesn't exist + logger.Info("Binary not found", "command", commandPath, "output", string(output)) + return false, nil + } + + // Binary exists + logger.Info("Binary found", "path", strings.TrimSpace(string(output))) + return true, nil +} + +// installBinary downloads and installs infer binary on remote server using the official install script +func (i *RemoteInstaller) installBinary() error { + // Get version to install + version := i.cfg.InstallVersion + var err error + if version == "latest" || version == "" { + version, err = i.getLatestVersion() + if err != nil { + return fmt.Errorf("failed to get latest version: %w", err) + } + } + + logger.Info("Installing version using install script", "version", version, "server", i.server.Name) + + // Determine install directory + installDir := "$HOME/bin" + if i.server.InstallPath != "" { + installDir = strings.TrimSuffix(i.server.InstallPath, "/infer") + } + + // Use the official install script which handles OS/arch detection and downloads the right binary + installScript := fmt.Sprintf(` +set -e +mkdir -p %s +echo "Downloading and running install script for version v%s..." +curl -fsSL https://raw.githubusercontent.com/inference-gateway/cli/main/install.sh | bash -s -- --version v%s --install-dir %s +echo "Installation complete!" +echo "Binary installed to: %s/infer" +if ! echo $PATH | grep -q "%s"; then + echo "Note: Add %s to your PATH to use 'infer' command globally" +fi +`, installDir, version, version, installDir, installDir, installDir, installDir) + + logger.Info("Running installation script", "server", i.server.Name) + + // Execute installation script + session, err := i.sshClient.NewSession() + if err != nil { + return fmt.Errorf("failed to create SSH session: %w", err) + } + defer func() { _ = session.Close() }() + + output, err := session.CombinedOutput(installScript) + if err != nil { + return fmt.Errorf("installation failed: %w\nOutput: %s", err, string(output)) + } + + logger.Info("Installation output", "output", string(output)) + + // Verify installation + session2, err := i.sshClient.NewSession() + if err != nil { + return fmt.Errorf("failed to create SSH session for verification: %w", err) + } + defer func() { _ = session2.Close() }() + + verifyCmd := fmt.Sprintf("%s/infer version", installDir) + verifyOutput, err := session2.CombinedOutput(verifyCmd) + if err != nil { + return fmt.Errorf("installation verification failed: %w\nOutput: %s", err, string(verifyOutput)) + } + + logger.Info("Installation verified", "version_output", string(verifyOutput)) + + return nil +} + +// getLatestVersion fetches the latest version from GitHub releases API +func (i *RemoteInstaller) getLatestVersion() (string, error) { + apiURL := "https://api.github.com/repos/inference-gateway/cli/releases/latest" + + logger.Info("Fetching latest version from GitHub", "url", apiURL) + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + // Set User-Agent to avoid GitHub API rate limiting + req.Header.Set("User-Agent", "inference-gateway-cli") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch latest version: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) + } + + var release struct { + TagName string `json:"tag_name"` + } + + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("failed to parse release info: %w", err) + } + + // Remove 'v' prefix if present + version := strings.TrimPrefix(release.TagName, "v") + + logger.Info("Latest version detected", "version", version) + + return version, nil +} diff --git a/internal/web/server.go b/internal/web/server.go index 6a5c73c6..3f1dc61c 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -68,6 +68,7 @@ func (s *WebTerminalServer) Start() error { mux := http.NewServeMux() mux.HandleFunc("/", s.handleIndex) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + mux.HandleFunc("/api/servers", s.handleServers) mux.HandleFunc("/ws", s.handleWebSocket) addr := fmt.Sprintf("%s:%d", s.cfg.Web.Host, s.cfg.Web.Port) @@ -111,6 +112,44 @@ func (s *WebTerminalServer) handleIndex(w http.ResponseWriter, r *http.Request) } } +func (s *WebTerminalServer) handleServers(w http.ResponseWriter, r *http.Request) { + type ServerInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Tags []string `json:"tags"` + } + + servers := []ServerInfo{} + + // Add local mode option + servers = append(servers, ServerInfo{ + ID: "local", + Name: "Local", + Description: "Run infer chat locally on this machine", + Tags: []string{"local"}, + }) + + // Add configured remote servers + for _, srv := range s.cfg.Web.Servers { + servers = append(servers, ServerInfo{ + ID: srv.ID, + Name: srv.Name, + Description: srv.Description, + Tags: srv.Tags, + }) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "servers": servers, + "ssh_enabled": s.cfg.Web.SSH.Enabled, + }); err != nil { + logger.Error("Failed to encode servers response", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + func (s *WebTerminalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { @@ -126,10 +165,10 @@ func (s *WebTerminalServer) handleWebSocket(w http.ResponseWriter, r *http.Reque sessionID := uuid.New().String() logger.Info("WebSocket connected", "remote", r.RemoteAddr, "session_id", sessionID) - session := s.sessionManager.CreateSession(sessionID) - defer s.sessionManager.RemoveSession(sessionID) - + // Wait for initial connection message with server selection cols, rows := 80, 24 + serverID := "local" // Default to local mode + if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { logger.Warn("Failed to set read deadline", "session_id", sessionID, "error", err) } @@ -140,30 +179,59 @@ func (s *WebTerminalServer) handleWebSocket(w http.ResponseWriter, r *http.Reque if err == nil && msgType == websocket.TextMessage { var msg struct { - Type string `json:"type"` - Cols int `json:"cols"` - Rows int `json:"rows"` + Type string `json:"type"` + ServerID string `json:"server_id"` + Cols int `json:"cols"` + Rows int `json:"rows"` } - if json.Unmarshal(data, &msg) == nil && msg.Type == "resize" { + if json.Unmarshal(data, &msg) == nil && msg.Type == "init" { cols, rows = msg.Cols, msg.Rows - logger.Info("Received initial terminal size", "session_id", sessionID, "cols", cols, "rows", rows) + serverID = msg.ServerID + logger.Info("Session initialized", + "session_id", sessionID, + "server_id", serverID, + "cols", cols, + "rows", rows) } } else if err != nil { - logger.Warn("Failed to read initial resize message, using defaults", "session_id", sessionID, "error", err) + logger.Warn("Failed to read init message, using defaults", + "session_id", sessionID, "error", err) } - if err := session.Start(cols, rows); err != nil { - logger.Error("Failed to start PTY session", "session_id", sessionID, "error", err) - if writeErr := conn.WriteMessage(websocket.TextMessage, []byte("Failed to start terminal session")); writeErr != nil { - logger.Warn("Failed to write error message to client", "session_id", sessionID, "error", writeErr) + serverCfg, ok := s.findServerConfig(serverID, sessionID, conn) + if !ok { + return + } + + handler, err := CreateSessionHandler(&s.cfg.Web, serverCfg, s.cfg, s.viper) + if err != nil { + logger.Error("Failed to create session", + "error", err, + "server_id", serverID) + errMsg := fmt.Sprintf("Failed to start session: %v", err) + if writeErr := conn.WriteMessage(websocket.TextMessage, []byte(errMsg)); writeErr != nil { + logger.Warn("Failed to write error message", "session_id", sessionID, "error", writeErr) } return } + defer func() { + if closeErr := handler.Close(); closeErr != nil { + logger.Warn("Failed to close session handler", "session_id", sessionID, "error", closeErr) + } + }() - logger.Info("PTY session started", "session_id", sessionID) + if err := handler.Start(cols, rows); err != nil { + logger.Error("Failed to start session", "error", err) + errMsg := fmt.Sprintf("Failed to start terminal: %v", err) + if writeErr := conn.WriteMessage(websocket.TextMessage, []byte(errMsg)); writeErr != nil { + logger.Warn("Failed to write error message", "session_id", sessionID, "error", writeErr) + } + return + } - handler := s.sessionManager.WrapSession(sessionID, session) + logger.Info("Session started", "session_id", sessionID, "server_id", serverID) + // Handle I/O if err := handler.HandleConnection(conn); err != nil { logger.Error("Connection error", "session_id", sessionID, "error", err) } @@ -171,6 +239,26 @@ func (s *WebTerminalServer) handleWebSocket(w http.ResponseWriter, r *http.Reque logger.Info("WebSocket connection closed", "session_id", sessionID) } +// findServerConfig finds server configuration by ID and handles error reporting +func (s *WebTerminalServer) findServerConfig(serverID, sessionID string, conn *websocket.Conn) (*config.SSHServerConfig, bool) { + if serverID == "local" { + return nil, true + } + + for i := range s.cfg.Web.Servers { + if s.cfg.Web.Servers[i].ID == serverID { + return &s.cfg.Web.Servers[i], true + } + } + + errMsg := fmt.Sprintf("Server not found: %s", serverID) + logger.Error("Invalid server ID", "session_id", sessionID, "server_id", serverID) + if writeErr := conn.WriteMessage(websocket.TextMessage, []byte(errMsg)); writeErr != nil { + logger.Warn("Failed to write error message", "session_id", sessionID, "error", writeErr) + } + return nil, false +} + func (s *WebTerminalServer) handleShutdown() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) diff --git a/internal/web/session_manager.go b/internal/web/session_manager.go index 4797b1c0..c652b562 100644 --- a/internal/web/session_manager.go +++ b/internal/web/session_manager.go @@ -76,7 +76,7 @@ func (sm *SessionManager) RemoveSession(sessionID string) { defer sm.mu.Unlock() if entry, exists := sm.sessions[sessionID]; exists { - if err := entry.session.Stop(); err != nil { + if err := entry.session.Close(); err != nil { logger.Warn("Error stopping session", "id", sessionID, "error", err) } delete(sm.sessions, sessionID) @@ -121,7 +121,7 @@ func (sm *SessionManager) cleanupInactiveSessions() { for _, sessionID := range toRemove { if entry, exists := sm.sessions[sessionID]; exists { logger.Info("Cleaning up inactive session", "id", sessionID, "inactive_duration", now.Sub(entry.lastActive), "threshold", inactiveThreshold) - if err := entry.session.Stop(); err != nil { + if err := entry.session.Close(); err != nil { logger.Warn("Error stopping inactive session", "id", sessionID, "error", err) } delete(sm.sessions, sessionID) @@ -151,7 +151,7 @@ func (sm *SessionManager) Shutdown() { for sessionID, entry := range sm.sessions { logger.Info("Stopping session", "id", sessionID) - if err := entry.session.Stop(); err != nil { + if err := entry.session.Close(); err != nil { logger.Warn("Error stopping session during shutdown", "id", sessionID, "error", err) } } @@ -160,22 +160,22 @@ func (sm *SessionManager) Shutdown() { logger.Info("All sessions stopped") } -// SessionHandler wraps a session to track activity -type SessionHandler struct { +// SessionWrapper wraps a session to track activity +type SessionWrapper struct { sessionID string session Session manager *SessionManager } -func (sm *SessionManager) WrapSession(sessionID string, session Session) *SessionHandler { - return &SessionHandler{ +func (sm *SessionManager) WrapSession(sessionID string, session Session) *SessionWrapper { + return &SessionWrapper{ sessionID: sessionID, session: session, manager: sm, } } -func (sh *SessionHandler) HandleConnection(conn *websocket.Conn) error { +func (sh *SessionWrapper) HandleConnection(conn *websocket.Conn) error { done := make(chan struct{}) go func() { ticker := time.NewTicker(10 * time.Second) diff --git a/internal/web/ssh_client.go b/internal/web/ssh_client.go new file mode 100644 index 00000000..bd5d4668 --- /dev/null +++ b/internal/web/ssh_client.go @@ -0,0 +1,228 @@ +package web + +import ( + "fmt" + "net" + "os" + "path/filepath" + "time" + + ssh "golang.org/x/crypto/ssh" + agent "golang.org/x/crypto/ssh/agent" + knownhosts "golang.org/x/crypto/ssh/knownhosts" + + config "github.com/inference-gateway/cli/config" + logger "github.com/inference-gateway/cli/internal/logger" +) + +// SSHClient manages SSH connections to remote servers +type SSHClient struct { + cfg *config.WebSSHConfig + server *config.SSHServerConfig + client *ssh.Client +} + +// NewSSHClient creates an SSH client for the specified server +func NewSSHClient(cfg *config.WebSSHConfig, server *config.SSHServerConfig) (*SSHClient, error) { + if server == nil { + return nil, fmt.Errorf("server configuration is required") + } + + return &SSHClient{ + cfg: cfg, + server: server, + }, nil +} + +// Connect establishes SSH connection to the remote server +func (c *SSHClient) Connect() error { + sshConfig, err := c.getSSHConfig() + if err != nil { + return fmt.Errorf("failed to create SSH config: %w", err) + } + + addr := net.JoinHostPort(c.server.RemoteHost, fmt.Sprintf("%d", c.server.RemotePort)) + + logger.Info("Connecting to SSH server", + "host", c.server.RemoteHost, + "port", c.server.RemotePort, + "user", c.server.RemoteUser) + + conn, err := net.DialTimeout("tcp", addr, 30*time.Second) + if err != nil { + return fmt.Errorf("failed to connect to %s: %w", addr, err) + } + + sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, sshConfig) + if err != nil { + if closeErr := conn.Close(); closeErr != nil { + logger.Warn("Failed to close connection after SSH handshake failure", "error", closeErr) + } + return fmt.Errorf("failed to establish SSH connection: %w", err) + } + + c.client = ssh.NewClient(sshConn, chans, reqs) + + logger.Info("SSH connection established", "server", c.server.Name) + return nil +} + +// NewSession creates a new SSH session +func (c *SSHClient) NewSession() (*ssh.Session, error) { + if c.client == nil { + return nil, fmt.Errorf("SSH client not connected") + } + + session, err := c.client.NewSession() + if err != nil { + return nil, fmt.Errorf("failed to create SSH session: %w", err) + } + + return session, nil +} + +// Close closes the SSH connection +func (c *SSHClient) Close() error { + if c.client != nil { + logger.Info("Closing SSH connection", "server", c.server.Name) + return c.client.Close() + } + return nil +} + +// getSSHConfig creates SSH client configuration with authentication +func (c *SSHClient) getSSHConfig() (*ssh.ClientConfig, error) { + var signers []ssh.Signer + var err error + + signers, err = connectSSHAgent() + if err != nil { + logger.Warn("SSH agent not available, falling back to key files", "error", err) + + signers, err = loadSSHKeysFromFiles() + if err != nil { + return nil, fmt.Errorf("failed to load SSH keys: %w (tried SSH agent and key files)", err) + } + logger.Info("Loaded SSH keys from files", "keys_available", len(signers)) + } else { + logger.Info("SSH agent connected", "keys_available", len(signers)) + } + + if len(signers) == 0 { + return nil, fmt.Errorf("no SSH keys found (tried SSH agent and ~/.ssh key files)") + } + + var hostKeyCallback ssh.HostKeyCallback + if c.cfg.KnownHostsPath != "" { + knownHostsPath := expandPath(c.cfg.KnownHostsPath) + hostKeyCallback, err = knownhosts.New(knownHostsPath) + if err != nil { + logger.Warn("Failed to load known_hosts, using insecure connection", + "path", knownHostsPath, + "error", err) + hostKeyCallback = ssh.InsecureIgnoreHostKey() + } else { + logger.Info("Using known_hosts for host key verification", "path", knownHostsPath) + } + } else { + logger.Warn("No known_hosts path configured, using insecure connection") + hostKeyCallback = ssh.InsecureIgnoreHostKey() + } + + return &ssh.ClientConfig{ + User: c.server.RemoteUser, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signers...), + }, + HostKeyCallback: hostKeyCallback, + Timeout: 30 * time.Second, + }, nil +} + +// connectSSHAgent connects to the SSH agent and returns available signers +func connectSSHAgent() ([]ssh.Signer, error) { + agentSock := os.Getenv("SSH_AUTH_SOCK") + if agentSock == "" { + return nil, fmt.Errorf("SSH_AUTH_SOCK environment variable not set") + } + + conn, err := net.Dial("unix", agentSock) + if err != nil { + return nil, fmt.Errorf("failed to connect to SSH agent at %s: %w", agentSock, err) + } + + agentClient := agent.NewClient(conn) + signers, err := agentClient.Signers() + if err != nil { + if closeErr := conn.Close(); closeErr != nil { + logger.Warn("Failed to close agent connection after error", "error", closeErr) + } + return nil, fmt.Errorf("failed to get signers from SSH agent: %w", err) + } + + return signers, nil +} + +// loadSSHKeysFromFiles loads SSH keys from standard file locations +func loadSSHKeysFromFiles() ([]ssh.Signer, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + + keyFiles := []string{ + filepath.Join(homeDir, ".ssh", "id_rsa"), + filepath.Join(homeDir, ".ssh", "id_ed25519"), + filepath.Join(homeDir, ".ssh", "id_ecdsa"), + } + + var signers []ssh.Signer + for _, keyFile := range keyFiles { + signer, err := loadPrivateKeyFile(keyFile) + if err != nil { + logger.Debug("Failed to load key file", "file", keyFile, "error", err) + continue + } + logger.Info("Loaded SSH key from file", "file", keyFile) + signers = append(signers, signer) + } + + if len(signers) == 0 { + return nil, fmt.Errorf("no SSH keys found in %s/.ssh/ (tried id_rsa, id_ed25519, id_ecdsa)", homeDir) + } + + return signers, nil +} + +// loadPrivateKeyFile loads a private key from a file +func loadPrivateKeyFile(path string) (ssh.Signer, error) { + keyData, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + signer, err := ssh.ParsePrivateKey(keyData) + if err == nil { + return signer, nil + } + + return nil, fmt.Errorf("failed to parse key (may be encrypted): %w", err) +} + +// expandPath expands ~ to home directory +func expandPath(path string) string { + if len(path) == 0 || path[0] != '~' { + return path + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return path + } + + if len(path) == 1 { + return homeDir + } + + return filepath.Join(homeDir, path[1:]) +} diff --git a/internal/web/ssh_session.go b/internal/web/ssh_session.go new file mode 100644 index 00000000..af10af40 --- /dev/null +++ b/internal/web/ssh_session.go @@ -0,0 +1,317 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "sync" + + websocket "github.com/gorilla/websocket" + ssh "golang.org/x/crypto/ssh" + + config "github.com/inference-gateway/cli/config" + logger "github.com/inference-gateway/cli/internal/logger" +) + +// SSHSession wraps an SSH session with PTY for remote terminal access +type SSHSession struct { + sshClient *SSHClient + server *config.SSHServerConfig + session *ssh.Session + stdin io.WriteCloser + stdout io.Reader + stderr io.Reader + mu sync.Mutex + running bool + ctx context.Context + cancel context.CancelFunc +} + +// NewSSHSession creates a new SSH session with PTY +func NewSSHSession(client *SSHClient, server *config.SSHServerConfig) (*SSHSession, error) { + if client == nil { + return nil, fmt.Errorf("SSH client is required") + } + if server == nil { + return nil, fmt.Errorf("server configuration is required") + } + + ctx, cancel := context.WithCancel(context.Background()) + + return &SSHSession{ + sshClient: client, + server: server, + ctx: ctx, + cancel: cancel, + }, nil +} + +// Start executes "infer chat" on the remote server with PTY +func (s *SSHSession) Start(cols, rows int) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.running { + return fmt.Errorf("session already started") + } + + session, err := s.sshClient.NewSession() + if err != nil { + return fmt.Errorf("failed to create SSH session: %w", err) + } + s.session = session + + modes := ssh.TerminalModes{ + ssh.ECHO: 1, // Enable echoing + ssh.TTY_OP_ISPEED: 14400, // Input speed = 14.4kbaud + ssh.TTY_OP_OSPEED: 14400, // Output speed = 14.4kbaud + } + + if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil { + if closeErr := session.Close(); closeErr != nil { + logger.Warn("Failed to close session after PTY request failure", "error", closeErr) + } + return fmt.Errorf("failed to request PTY: %w", err) + } + + stdin, err := session.StdinPipe() + if err != nil { + if closeErr := session.Close(); closeErr != nil { + logger.Warn("Failed to close session after stdin pipe failure", "error", closeErr) + } + return fmt.Errorf("failed to create stdin pipe: %w", err) + } + s.stdin = stdin + + stdout, err := session.StdoutPipe() + if err != nil { + if closeErr := session.Close(); closeErr != nil { + logger.Warn("Failed to close session after stdout pipe failure", "error", closeErr) + } + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + s.stdout = stdout + + stderr, err := session.StderrPipe() + if err != nil { + if closeErr := session.Close(); closeErr != nil { + logger.Warn("Failed to close session after stderr pipe failure", "error", closeErr) + } + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + s.stderr = stderr + + commandPath := s.server.CommandPath + if commandPath == "" { + commandPath = "infer" + } + + cmdArgs := append([]string{"chat"}, s.server.CommandArgs...) + cmd := fmt.Sprintf("%s %s", commandPath, strings.Join(cmdArgs, " ")) + + logger.Info("Starting remote command", + "command", cmd, + "server", s.server.Name, + "cols", cols, + "rows", rows) + + if err := session.Start(cmd); err != nil { + if closeErr := session.Close(); closeErr != nil { + logger.Warn("Failed to close session after command start failure", "error", closeErr) + } + return fmt.Errorf("failed to start remote command: %w", err) + } + + s.running = true + + go func() { + err := session.Wait() + if err != nil { + logger.Error("SSH session exited with error", "error", err, "server", s.server.Name) + } else { + logger.Info("SSH session exited normally", "server", s.server.Name) + } + s.mu.Lock() + s.running = false + s.mu.Unlock() + s.cancel() + }() + + return nil +} + +// Resize changes the PTY window size +func (s *SSHSession) Resize(cols, rows int) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.session == nil { + return fmt.Errorf("session not started") + } + + logger.Info("Resizing terminal", "cols", cols, "rows", rows, "server", s.server.Name) + + if err := s.session.WindowChange(rows, cols); err != nil { + return fmt.Errorf("failed to resize window: %w", err) + } + + return nil +} + +// HandleConnection bridges WebSocket and SSH session I/O +func (s *SSHSession) HandleConnection(conn *websocket.Conn) error { + var wg sync.WaitGroup + errChan := make(chan error, 2) + + wg.Add(1) + go func() { + defer wg.Done() + if err := s.handleWebSocketInput(conn); err != nil { + errChan <- fmt.Errorf("websocket input error: %w", err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + if err := s.handleSSHOutput(conn); err != nil { + errChan <- fmt.Errorf("ssh output error: %w", err) + } + }() + + wg.Wait() + close(errChan) + + for err := range errChan { + if err != nil { + return err + } + } + + return nil +} + +// handleWebSocketInput reads from WebSocket and writes to SSH stdin +func (s *SSHSession) handleWebSocketInput(conn *websocket.Conn) error { + for { + select { + case <-s.ctx.Done(): + return nil + default: + } + + msgType, data, err := conn.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + logger.Info("WebSocket closed normally", "server", s.server.Name) + return nil + } + return fmt.Errorf("failed to read from websocket: %w", err) + } + + if err := s.handleWebSocketMessage(msgType, data); err != nil { + return err + } + } +} + +// handleWebSocketMessage processes WebSocket messages (resize or stdin data) +func (s *SSHSession) handleWebSocketMessage(msgType int, data []byte) error { + if msgType == websocket.TextMessage { + return s.handleTextMessage(data) + } + + if msgType == websocket.BinaryMessage { + if _, err := s.stdin.Write(data); err != nil { + return fmt.Errorf("failed to write to ssh stdin: %w", err) + } + } + + return nil +} + +// handleTextMessage processes text WebSocket messages (resize or stdin) +func (s *SSHSession) handleTextMessage(data []byte) error { + var msg struct { + Type string `json:"type"` + Cols int `json:"cols"` + Rows int `json:"rows"` + } + + if json.Unmarshal(data, &msg) == nil && msg.Type == "resize" { + if err := s.Resize(msg.Cols, msg.Rows); err != nil { + logger.Warn("Failed to resize terminal", "error", err) + } + return nil + } + + if _, err := s.stdin.Write(data); err != nil { + return fmt.Errorf("failed to write to ssh stdin: %w", err) + } + + return nil +} + +// handleSSHOutput reads from SSH stdout/stderr and writes to WebSocket +func (s *SSHSession) handleSSHOutput(conn *websocket.Conn) error { + output := io.MultiReader(s.stdout, s.stderr) + buf := make([]byte, 32*1024) // 32KB buffer + + for { + select { + case <-s.ctx.Done(): + return nil + default: + } + + n, err := output.Read(buf) + if err != nil { + if err == io.EOF { + logger.Info("SSH output stream closed", "server", s.server.Name) + return nil + } + return fmt.Errorf("failed to read from ssh output: %w", err) + } + + if n > 0 { + if err := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { + return fmt.Errorf("failed to write to websocket: %w", err) + } + } + } +} + +// Close terminates the SSH session +func (s *SSHSession) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + logger.Info("Closing SSH session", "server", s.server.Name) + + s.cancel() + + var errors []error + + if s.session != nil { + if err := s.session.Close(); err != nil { + errors = append(errors, fmt.Errorf("failed to close session: %w", err)) + } + s.session = nil + } + + if s.sshClient != nil { + if err := s.sshClient.Close(); err != nil { + errors = append(errors, fmt.Errorf("failed to close SSH client: %w", err)) + } + } + + s.running = false + + if len(errors) > 0 { + return fmt.Errorf("errors during close: %v", errors) + } + + return nil +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 42e466e7..94be0f68 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -6,15 +6,52 @@ class TerminalManager { this.tabBar = document.getElementById('tab-bar'); this.terminalArea = document.getElementById('terminal-area'); this.newTabBtn = document.getElementById('new-tab-btn'); + this.serverSelector = document.getElementById('server-selector'); + this.servers = []; + this.currentServerID = 'local'; + this.loadServers(); this.newTabBtn.addEventListener('click', () => this.createTab()); + this.serverSelector.addEventListener('change', (e) => { + this.currentServerID = e.target.value; + }); + } + + async loadServers() { + try { + const response = await fetch('/api/servers'); + const data = await response.json(); + this.servers = data.servers; + + // Populate dropdown + this.serverSelector.innerHTML = ''; + this.servers.forEach(server => { + const option = document.createElement('option'); + option.value = server.id; + option.textContent = server.name; + if (server.description) { + option.title = server.description; + } + this.serverSelector.appendChild(option); + }); + + // Set default selection + this.serverSelector.value = 'local'; + this.currentServerID = 'local'; - this.createTab(); + // Create first tab after servers are loaded + this.createTab(); + } catch (error) { + console.error('Failed to load servers:', error); + // Fallback: create tab anyway with local mode + this.createTab(); + } } createTab() { const tabId = this.nextTabId++; - const tab = new TerminalTab(tabId, this); + const serverID = this.currentServerID; + const tab = new TerminalTab(tabId, this, serverID); this.tabs.set(tabId, tab); this.switchTab(tabId); } @@ -54,9 +91,10 @@ class TerminalManager { } class TerminalTab { - constructor(id, manager) { + constructor(id, manager, serverID = 'local') { this.id = id; this.manager = manager; + this.serverID = serverID; this.socket = null; this.term = null; this.fitAddon = null; @@ -147,11 +185,18 @@ class TerminalTab { this.socket = new WebSocket(wsUrl); this.socket.onopen = () => { - console.log(`Tab ${this.id}: WebSocket connected`); + console.log(`Tab ${this.id}: WebSocket connected to server: ${this.serverID}`); this.connected = true; this.fitAddon.fit(); - this.sendResize(); + + // Send init message with server selection + this.socket.send(JSON.stringify({ + type: 'init', + server_id: this.serverID, + cols: this.term.cols, + rows: this.term.rows + })); requestAnimationFrame(() => { this.term.focus(); @@ -221,5 +266,7 @@ class TerminalTab { } } -// Initialize -const terminalManager = new TerminalManager(); +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + const terminalManager = new TerminalManager(); +}); diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 93a313a5..d81f0121 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -68,6 +68,35 @@ opacity: 1; color: #1a1b26; } + #server-selector-container { + display: flex; + align-items: center; + gap: 8px; + margin-right: 12px; + padding: 0 8px; + } + #server-selector-label { + font-size: 13px; + color: #a9b1d6; + } + #server-selector { + background: #24283b; + color: #a9b1d6; + border: 1px solid #414868; + padding: 4px 8px; + border-radius: 4px; + font-size: 13px; + min-width: 150px; + cursor: pointer; + } + #server-selector:focus { + outline: none; + border-color: #7aa2f7; + } + #server-selector option { + background: #1a1b26; + color: #a9b1d6; + } #new-tab-btn { display: flex; align-items: center; @@ -107,6 +136,12 @@
+
+ + +