diff --git a/.DS_Store b/.DS_Store index 8088c65..411b7a8 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/README.md b/README.md index 407569f..c3c40c2 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ -# OpenClaw on DigitalOcean +# OpenClaw on DigitalOcean + Azure [![Security Checks](https://github.com/PCBZ/OpenClaw_Docker/actions/workflows/security.yml/badge.svg)](https://github.com/PCBZ/OpenClaw_Docker/actions/workflows/security.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Last Commit](https://img.shields.io/github/last-commit/PCBZ/OpenClaw_Docker)](https://github.com/PCBZ/OpenClaw_Docker/commits/main) [![Terraform](https://img.shields.io/badge/Terraform-%3E%3D1.5-844fba?logo=terraform&logoColor=white)](https://www.terraform.io) [![DigitalOcean](https://img.shields.io/badge/DigitalOcean-Droplet-0080ff?logo=digitalocean&logoColor=white)](https://www.digitalocean.com) +[![Azure](https://img.shields.io/badge/Azure-VM-0078d4?logo=microsoft-azure&logoColor=white)](https://azure.microsoft.com) [![OpenRouter](https://img.shields.io/badge/OpenRouter-Free%20Tier-ff6b35?logoColor=white)](https://openrouter.ai) [![OpenClaw](https://img.shields.io/badge/OpenClaw-2026-00e5cc?logoColor=white)](https://openclaw.bot) [![Telegram](https://img.shields.io/badge/Telegram-Bot-26a5e4?logo=telegram&logoColor=white)](https://telegram.org) [![Slack](https://img.shields.io/badge/Slack-Bot-4a154b?logo=slack&logoColor=white)](https://slack.com) -One-command deployment of an [OpenClaw](https://openclaw.bot) AI agent on DigitalOcean with Telegram support and Slack support. After `terraform apply`, the bot is fully operational with no manual SSH steps required. +One-command deployment of an [OpenClaw](https://openclaw.bot) AI agent on DigitalOcean or Azure VM with Telegram and Slack support. After `terraform apply`, the bot is fully operational with no manual SSH steps required. ## Features @@ -25,7 +26,8 @@ One-command deployment of an [OpenClaw](https://openclaw.bot) AI agent on Digita - Terraform >= 1.5 - direnv (`brew install direnv`) - SSH key pair -- DigitalOcean account + API token +- DigitalOcean account + API token (for DO path) +- Azure subscription + service principal credentials (for Azure path) - OpenRouter API key - Telegram bot token (from [@BotFather](https://t.me/BotFather)) - Slack App-Level token (starts with `xapp-`) @@ -51,7 +53,9 @@ Edit `.env` and fill in your values: | `SLACK_APP_TOKEN` | Slack App-Level token (starts with `xapp-`) | | `SLACK_BOT_TOKEN` | Slack Bot User OAuth token (starts with `xoxb-`) | -### 2. Configure infrastructure +### 2. Choose deployment target + +#### Option A: DigitalOcean ```bash cd terraform/digitalOcean @@ -67,6 +71,31 @@ droplet_size = "s-1vcpu-1gb" # $6/mo — increase if OOM swap_size = "3G" ``` +#### Option B: Azure VM + +```bash +cd terraform/azure_vm +``` + +Create `terraform.tfvars` and set your Azure + VM values: + +```hcl +subscription_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +tenant_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +client_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +client_secret = "..." + +resource_group_name = "your-existing-rg" +location = "eastus" +ssh_public_key_path = "~/.ssh/id_rsa.pub" + +vm_name = "openclaw-b2pts" +vm_size = "Standard_B2pts_v2" +os_disk_size_gb = 30 +swap_size = 2 +openclaw_memory_limit_mb = 800 +``` + ### 3. Load secrets via direnv ```bash diff --git a/terraform/azure_vm/.envrc b/terraform/azure_vm/.envrc new file mode 100644 index 0000000..5707998 --- /dev/null +++ b/terraform/azure_vm/.envrc @@ -0,0 +1,12 @@ +# Auto-load secrets from .env into Terraform variables +# Requires: brew install direnv && eval "$(direnv hook zsh)" >> ~/.zshrc + +dotenv ../../.env + +export TF_VAR_openrouter_api_key="$OPENROUTER_API_KEY" +export TF_VAR_telegram_bot_token="$TELEGRAM_BOT_TOKEN" +export TF_VAR_openclaw_gateway_token="$OPENCLAW_GATEWAY_TOKEN" +export TF_VAR_brave_api_key="${BRAVE_API_KEY:-}" +export TF_VAR_telegram_owner_id="${TELEGRAM_OWNER_ID:-}" +export TF_VAR_slack_app_token="${SLACK_APP_TOKEN:-}" +export TF_VAR_slack_bot_token="${SLACK_BOT_TOKEN:-}" diff --git a/terraform/azure_vm/approve_operator_approvals.py b/terraform/azure_vm/approve_operator_approvals.py new file mode 100644 index 0000000..75a72fb --- /dev/null +++ b/terraform/azure_vm/approve_operator_approvals.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Grant operator.approvals on paired devices; clear processed pending requests.""" +import json +import time + +PENDING_FILE = "/root/.openclaw/devices/pending.json" +PAIRED_FILE = "/root/.openclaw/devices/paired.json" + +pending = {} +for _ in range(12): + try: + with open(PENDING_FILE) as f: + pending = json.load(f) + if pending: + break + except Exception: + pass + time.sleep(5) + +# Even if no pending requests, approve operator.approvals for all paired devices. +try: + with open(PAIRED_FILE) as f: + paired = json.load(f) +except Exception: + paired = {} + +for request in pending.values(): + device_id = request.get("deviceId") + if device_id in paired: + for key in ("scopes", "approvedScopes"): + if "operator.approvals" not in paired[device_id].get(key, []): + paired[device_id].setdefault(key, []).append("operator.approvals") + print(f"Approved via pending: {device_id}") + +# Ensure all paired operator devices have operator.approvals regardless of pending state +for device_id, device in paired.items(): + if "operator" in device.get("roles", []): + for key in ("scopes", "approvedScopes"): + if "operator.approvals" not in device.get(key, []): + device.setdefault(key, []).append("operator.approvals") + print(f"Granted operator.approvals to {device_id[:16]}...") + +if paired: + with open(PAIRED_FILE, "w") as f: + json.dump(paired, f, indent=2) +if pending: + with open(PENDING_FILE, "w") as f: + json.dump({}, f) diff --git a/terraform/azure_vm/bootstrap.sh b/terraform/azure_vm/bootstrap.sh new file mode 100644 index 0000000..fd11cbd --- /dev/null +++ b/terraform/azure_vm/bootstrap.sh @@ -0,0 +1,185 @@ +#!/bin/bash +set -e + +export HOME=/root +export USER=root +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# ── 1. System setup ────────────────────────────────────────── +apt-get update -y + +# ── Create swap with temp-disk fallback ────────────────────── +swap_size_gb=${swap_size} +swap_size_mb=$((swap_size_gb * 1024)) +swap_path="/mnt/resource/swapfile" +if [ ! -d "/mnt/resource" ]; then + swap_path="/swapfile" +fi + +echo "Setting up $${swap_size_gb}GB swap at $${swap_path}..." +if dd if=/dev/zero of="$${swap_path}" bs=1M count="$${swap_size_mb}"; then + chmod 600 "$${swap_path}" + mkswap "$${swap_path}" + swapon "$${swap_path}" + if ! grep -q "$${swap_path} none swap" /etc/fstab; then + echo "$${swap_path} none swap sw 0 0" >> /etc/fstab + fi +else + echo "WARNING: Swap creation failed; continuing without swap." +fi + +# ── Kernel swappiness tuning ───────────────────────────────── +cat > /etc/sysctl.d/99-swappiness.conf << 'SYSCTLEOF' +vm.swappiness = 20 +vm.overcommit_memory = 1 +SYSCTLEOF +sysctl -p /etc/sysctl.d/99-swappiness.conf + +# ── Install Node.js 24.x ──────────────────────────────────── +curl -fsSL https://deb.nodesource.com/setup_24.x | bash - +apt-get install -y nodejs + +# ── 2. Install OpenClaw ────────────────────────────────────── +export OPENCLAW_ONBOARD_NON_INTERACTIVE=1 +export OPENCLAW_INSTALL_METHOD=npm +curl -fsSL https://openclaw.bot/install.sh | bash -s -- --install-method npm --no-onboard + +npm install -g grammy @grammyjs/runner @grammyjs/transformer-throttler \ + @slack/bolt @slack/socket-mode @slack/web-api + +# ── 3. Write config ────────────────────────────────────────── +mkdir -p /root/.openclaw + +# Write injected openclaw.json (provided by Terraform) +cat > /root/.openclaw/openclaw.json << 'JSONEOF' +${openclaw_json_content} +JSONEOF + +mkdir -p /root/.openclaw/workspace +if ! grep -q "ALWAYS_REPLY_IN_DM" /root/.openclaw/workspace/AGENTS.md 2>/dev/null; then +cat >> /root/.openclaw/workspace/AGENTS.md << 'AGENTSEOF' + +## Channel Output Rule (OpenClaw) + +- ALWAYS_REPLY_IN_DM: For any direct message on Telegram/Slack, always send at least one plain-text assistant message. +- Never end a DM turn with tool calls only, empty payload, or metadata-only output. +- If uncertain, send a brief fallback text: "I can help with that. Could you share a bit more detail?" +AGENTSEOF +fi + +# ── 4. Write .env and export secrets ───────────────────────── +cat > /root/.openclaw/.env << 'ENVEOF' +OPENROUTER_API_KEY=${openrouter_api_key} +TELEGRAM_BOT_TOKEN=${telegram_bot_token} +OPENCLAW_GATEWAY_TOKEN=${openclaw_gateway_token} +BRAVE_API_KEY=${brave_api_key} +OPENCLAW_ONBOARD_NON_INTERACTIVE=1 +ENVEOF + +export OPENROUTER_API_KEY=${openrouter_api_key} +export TELEGRAM_BOT_TOKEN=${telegram_bot_token} +export OPENCLAW_GATEWAY_TOKEN=${openclaw_gateway_token} +export BRAVE_API_KEY=${brave_api_key} + +# ── 5. Onboard ─────────────────────────────────────────────── +openclaw doctor --fix || true + +loginctl enable-linger root +export XDG_RUNTIME_DIR=/run/user/0 +mkdir -p "$XDG_RUNTIME_DIR" +systemctl start user@0.service || true + +openclaw onboard --non-interactive --accept-risk --install-daemon || true + +# Fix models.json baseUrl (onboard may have written wrong /v1 instead of /api/v1) +MODELS_JSON=/root/.openclaw/agents/main/agent/models.json +if [ -f "$MODELS_JSON" ]; then + sed -i 's|https://openrouter.ai/v1|https://openrouter.ai/api/v1|g' "$MODELS_JSON" + echo "Fixed models.json baseUrl: /v1 -> /api/v1" +fi + +# ── Write agent auth-profiles (OpenRouter key) ─────────────── +mkdir -p /root/.openclaw/agents/main/agent +cat > /root/.openclaw/agents/main/agent/auth-profiles.json << 'AUTHEOF' +{ + "openrouter": { + "apiKey": "${openrouter_api_key}" + } +} +AUTHEOF + +openclaw gateway install --force + +# ── 6. Create systemd service override with memory limits ──── +mkdir -p /root/.config/systemd/user/openclaw-gateway.service.d +cat > /root/.config/systemd/user/openclaw-gateway.service.d/override.conf << 'OVERRIDEEOF' +[Service] +TimeoutStartSec=180 +TimeoutStopSec=60 +RestartSec=5 +MemoryLimit=${openclaw_memory_limit_mb}M +OVERRIDEEOF + +systemctl --user daemon-reload +systemctl --user restart openclaw-gateway.service + +# ── 7. Auto-approve operator.approvals scope ───────────────── +echo "Waiting for approval requests..." +sleep 120 + +mkdir -p /root/.openclaw/bootstrap +cat > /root/.openclaw/bootstrap/approve_operator_approvals.py << 'APPROVEPYEOF' +${approve_operator_script} +APPROVEPYEOF +chmod 700 /root/.openclaw/bootstrap/approve_operator_approvals.py +python3 /root/.openclaw/bootstrap/approve_operator_approvals.py + +# ── 8. Setup unattended security updates ───────────────────── +apt-get install -y unattended-upgrades + +cat > /etc/apt/apt.conf.d/50unattended-upgrades << 'UNATTENDEDEOF' +Unattended-Upgrade::Allowed-Origins { + "$${distro_id}:$${distro_codename}-security"; +}; +Unattended-Upgrade::AutoFixInterruptedDpkg "true"; +Unattended-Upgrade::MinimalSteps "true"; +Unattended-Upgrade::Remove-Unused-Kernel-Packages "true"; +Unattended-Upgrade::Remove-Unused-Dependencies "true"; +Unattended-Upgrade::Automatic-Reboot "true"; +Unattended-Upgrade::Automatic-Reboot-Time "03:00"; +UNATTENDEDEOF + +# ── 9. Setup cleanup cron jobs ─────────────────────────────── +cat > /etc/cron.d/openclaw-cleanup << 'CRONEOF' +# OpenClaw cleanup and maintenance tasks +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +CRON_TZ=America/Los_Angeles + +# Weekly npm cache cleanup (Sunday 2:00 Pacific) +0 2 * * 0 root npm cache clean --force >> /var/log/openclaw-cleanup.log 2>&1 + +# Daily disk space logging (2:00 Pacific) +0 2 * * * root df -h > /var/log/openclaw-diskspace.log 2>&1 +CRONEOF + +# ── 10. Setup logrotate for OpenClaw logs ──────────────────── +mkdir -p /var/log/openclaw + +cat > /etc/logrotate.d/openclaw << 'LOGROTATEEOF' +/var/log/openclaw-*.log { + daily + rotate 7 + compress + delaycompress + notifempty + create 0644 root root + missingok +} +LOGROTATEEOF + +echo "=== Azure Bootstrap Complete ===" +echo "Swap: $${swap_size_gb}GB at $${swap_path}" +echo "Memory Limit: ${openclaw_memory_limit_mb}MB (systemd cgroup)" +echo "OpenClaw Web: http://localhost:18789/health (test locally)" +echo "Check systemd: systemctl --user status openclaw-gateway" diff --git a/terraform/azure_vm/compute.tf b/terraform/azure_vm/compute.tf new file mode 100644 index 0000000..081abfc --- /dev/null +++ b/terraform/azure_vm/compute.tf @@ -0,0 +1,36 @@ +resource "azurerm_linux_virtual_machine" "main" { + name = var.vm_name + location = var.location + resource_group_name = data.azurerm_resource_group.main.name + size = var.vm_size + + admin_username = var.admin_username + + admin_ssh_key { + username = var.admin_username + public_key = file(var.ssh_public_key_path) + } + + network_interface_ids = [ + azurerm_network_interface.main.id, + ] + + os_disk { + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + disk_size_gb = var.os_disk_size_gb + } + + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-arm64" + version = "latest" + } + + # Temp disk is automatically allocated for B1s (4GB at /mnt/resource) + # No explicit configuration needed, but ensure it's not disabled by default + custom_data = base64encode(templatefile("${path.module}/bootstrap.sh", local.bootstrap_vars)) + + tags = local.common_tags +} diff --git a/terraform/azure_vm/data.tf b/terraform/azure_vm/data.tf new file mode 100644 index 0000000..fbc8c98 --- /dev/null +++ b/terraform/azure_vm/data.tf @@ -0,0 +1,3 @@ +data "azurerm_resource_group" "main" { + name = var.resource_group_name +} diff --git a/terraform/azure_vm/locals.tf b/terraform/azure_vm/locals.tf new file mode 100644 index 0000000..cd06c75 --- /dev/null +++ b/terraform/azure_vm/locals.tf @@ -0,0 +1,27 @@ +locals { + openclaw_json_content = templatefile("${path.module}/openclaw.json.tpl", { + openclaw_gateway_token = var.openclaw_gateway_token + openrouter_api_key = var.openrouter_api_key + brave_api_key = var.brave_api_key + telegram_bot_token = var.telegram_bot_token + slack_app_token = var.slack_app_token + slack_bot_token = var.slack_bot_token + slack_enabled = var.slack_app_token != "" && var.slack_bot_token != "" + }) + + bootstrap_vars = { + openrouter_api_key = var.openrouter_api_key + telegram_bot_token = var.telegram_bot_token + openclaw_gateway_token = var.openclaw_gateway_token + brave_api_key = var.brave_api_key + swap_size = var.swap_size + openclaw_memory_limit_mb = var.openclaw_memory_limit_mb + approve_operator_script = file("${path.module}/approve_operator_approvals.py") + openclaw_json_content = local.openclaw_json_content + } + + common_tags = { + Environment = "Production" + Application = "OpenClaw" + } +} diff --git a/terraform/azure_vm/main.tf b/terraform/azure_vm/main.tf new file mode 100644 index 0000000..375121e --- /dev/null +++ b/terraform/azure_vm/main.tf @@ -0,0 +1,7 @@ +# Terraform automatically loads all *.tf files in this directory. +# Resources are split by concern: +# - data.tf +# - locals.tf +# - network.tf +# - security.tf +# - compute.tf diff --git a/terraform/azure_vm/network.tf b/terraform/azure_vm/network.tf new file mode 100644 index 0000000..cd4fcd7 --- /dev/null +++ b/terraform/azure_vm/network.tf @@ -0,0 +1,34 @@ +resource "azurerm_virtual_network" "main" { + name = "${var.vm_name}-vnet" + address_space = ["10.0.0.0/16"] + location = var.location + resource_group_name = data.azurerm_resource_group.main.name +} + +resource "azurerm_subnet" "internal" { + name = "${var.vm_name}-subnet" + resource_group_name = data.azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = ["10.0.2.0/24"] +} + +resource "azurerm_public_ip" "main" { + name = var.public_ip_name + location = var.location + resource_group_name = data.azurerm_resource_group.main.name + allocation_method = "Static" + sku = "Standard" +} + +resource "azurerm_network_interface" "main" { + name = "${var.vm_name}-nic" + location = var.location + resource_group_name = data.azurerm_resource_group.main.name + + ip_configuration { + name = "testconfiguration1" + subnet_id = azurerm_subnet.internal.id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = azurerm_public_ip.main.id + } +} diff --git a/terraform/azure_vm/openclaw.json.tpl b/terraform/azure_vm/openclaw.json.tpl new file mode 100644 index 0000000..bb53bcf --- /dev/null +++ b/terraform/azure_vm/openclaw.json.tpl @@ -0,0 +1,90 @@ +{ + "gateway": { + "bind": "lan", + "auth": { "mode": "token", "token": "${openclaw_gateway_token}" }, + "mode": "local", + "remote": { "token": "${openclaw_gateway_token}" } + }, + "agents": { + "defaults": { + "model": { + "primary": "openrouter/openai/gpt-4o-mini", + "fallbacks": [ + "openrouter/anthropic/claude-haiku-4.5", + "openrouter/meta-llama/llama-3.3-70b-instruct:free", + "openrouter/auto" + ] + }, + "models": { + "openrouter/anthropic/claude-opus-4.6": {"alias": "opus"}, + "openrouter/anthropic/claude-sonnet-4.6": {"alias": "sonnet"}, + "openrouter/anthropic/claude-haiku-4.5": {"alias": "haiku"}, + "openrouter/openai/gpt-5.4": {"alias": "gpt5"}, + "openrouter/openai/gpt-4o": {"alias": "gpt4o"}, + "openrouter/openai/gpt-4o-mini": {"alias": "mini"}, + "openrouter/google/gemini-2.5-pro": {"alias": "gemini-pro"}, + "openrouter/google/gemini-2.5-flash": {"alias": "flash"}, + "openrouter/deepseek/deepseek-r1": {"alias": "r1"}, + "openrouter/mistralai/devstral-small": {"alias": "devstral"}, + "openrouter/meta-llama/llama-3.3-70b-instruct:free": {"alias": "llama"}, + "openrouter/nvidia/nemotron-3-super-120b-a12b:free": {"alias": "nemotron"}, + "openrouter/qwen/qwen3-coder:free": {"alias": "coder"}, + "openrouter/cognitivecomputations/dolphin-mistral-24b-venice-edition:free": {"alias": "uncensored"}, + "openrouter/auto": {"alias": "auto"} + }, + "compaction": { "mode": "safeguard", "reserveTokensFloor": 4000 } + } + }, + "tools": { + "web": { + "search": { "enabled": true, "provider": "brave" }, + "fetch": { + "enabled": true, + "strip_images": true, + "strip_videos": true, + "strip_css": true, + "strip_fonts": true + } + }, + "deny": ["browser"] + }, + "plugins": { + "load": { + "paths": [ + "/usr/lib/node_modules/openclaw/dist/extensions/telegram"%{ if slack_enabled }, + "/usr/lib/node_modules/openclaw/dist/extensions/slack"%{ endif } + ] + }, + "entries": { + "telegram": { "enabled": true }, +%{ if slack_enabled } + "slack": { "enabled": true }, +%{ endif } + "openrouter": { "enabled": true }, + "brave": { + "enabled": true, + "config": { "webSearch": { "apiKey": "${brave_api_key}" } } + } + } + }, + "channels": { + "telegram": { + "enabled": true, + "accounts": { + "default": { + "botToken": "${telegram_bot_token}", + "dmPolicy": "open", + "groupPolicy": "open" + } + } + }%{ if slack_enabled }, + "slack": { + "enabled": true, + "mode": "socket", + "appToken": "${slack_app_token}", + "botToken": "${slack_bot_token}", + "dmPolicy": "open", + "groupPolicy": "open" + }%{ endif } + } +} diff --git a/terraform/azure_vm/outputs.tf b/terraform/azure_vm/outputs.tf new file mode 100644 index 0000000..04e3ed1 --- /dev/null +++ b/terraform/azure_vm/outputs.tf @@ -0,0 +1,24 @@ +output "vm_public_ip" { + description = "Public IP address of the VM (use this for SSH)" + value = azurerm_public_ip.main.ip_address +} + +output "vm_name" { + description = "Name of the VM" + value = azurerm_linux_virtual_machine.main.name +} + +output "vm_private_ip" { + description = "Private IP address of the VM" + value = azurerm_network_interface.main.private_ip_address +} + +output "ssh_command" { + description = "SSH command to connect to the VM" + value = "ssh ${var.admin_username}@${azurerm_public_ip.main.ip_address}" +} + +output "openclaw_gateway_url" { + description = "OpenClaw gateway health check URL (after bootstrap completes)" + value = "http://${azurerm_public_ip.main.ip_address}:18789/health" +} diff --git a/terraform/azure_vm/provider.tf b/terraform/azure_vm/provider.tf new file mode 100644 index 0000000..e08aa1e --- /dev/null +++ b/terraform/azure_vm/provider.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.0" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +} + +provider "azurerm" { + features {} + + subscription_id = var.subscription_id + client_id = var.client_id + client_secret = var.client_secret + tenant_id = var.tenant_id +} diff --git a/terraform/azure_vm/security.tf b/terraform/azure_vm/security.tf new file mode 100644 index 0000000..5843dea --- /dev/null +++ b/terraform/azure_vm/security.tf @@ -0,0 +1,78 @@ +locals { + ssh_rule_defs = { + for idx, cidr in var.ssh_allowed_cidrs : "ssh-${idx}" => { + name = "AllowSSH_${idx}" + priority = 100 + idx + port = "22" + source_cidr = cidr + dest_prefix = length(regexall(":", cidr)) > 0 ? "::/0" : "*" + } + } + + gateway_rule_defs = { + for idx, cidr in var.gateway_allowed_cidrs : "gateway-${idx}" => { + name = "AllowOpenClawGateway_${idx}" + priority = 200 + idx + port = "18789" + source_cidr = cidr + dest_prefix = length(regexall(":", cidr)) > 0 ? "::/0" : "*" + } + } + + inbound_allow_rules = merge(local.ssh_rule_defs, local.gateway_rule_defs) +} + +resource "azurerm_network_security_group" "main" { + name = "${var.vm_name}-nsg" + location = var.location + resource_group_name = data.azurerm_resource_group.main.name +} + +resource "azurerm_network_security_rule" "inbound_allow" { + for_each = local.inbound_allow_rules + + name = each.value.name + priority = each.value.priority + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = each.value.port + source_address_prefix = each.value.source_cidr + destination_address_prefix = each.value.dest_prefix + resource_group_name = data.azurerm_resource_group.main.name + network_security_group_name = azurerm_network_security_group.main.name +} + +resource "azurerm_network_security_rule" "deny_all_inbound" { + name = "DenyAllInbound" + priority = 4096 + direction = "Inbound" + access = "Deny" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "*" + destination_address_prefix = "*" + resource_group_name = data.azurerm_resource_group.main.name + network_security_group_name = azurerm_network_security_group.main.name +} + +resource "azurerm_network_security_rule" "allow_all_outbound" { + name = "AllowAllOutbound" + priority = 100 + direction = "Outbound" + access = "Allow" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "*" + destination_address_prefix = "*" + resource_group_name = data.azurerm_resource_group.main.name + network_security_group_name = azurerm_network_security_group.main.name +} + +resource "azurerm_network_interface_security_group_association" "main" { + network_interface_id = azurerm_network_interface.main.id + network_security_group_id = azurerm_network_security_group.main.id +} diff --git a/terraform/azure_vm/variables.tf b/terraform/azure_vm/variables.tf new file mode 100644 index 0000000..6cc5803 --- /dev/null +++ b/terraform/azure_vm/variables.tf @@ -0,0 +1,129 @@ +# ── Azure Authentication ──────────────────────────────────── +# These come from terraform.tfvars +variable "subscription_id" { + sensitive = true +} + +variable "client_id" { + sensitive = true +} + +variable "client_secret" { + sensitive = true +} + +variable "tenant_id" { + sensitive = true +} + +# ── Azure Resource Group ──────────────────────────────────── +variable "resource_group_name" { + description = "Azure resource group name (must already exist)" + type = string +} + +variable "location" { + description = "Azure region (e.g., eastus, canadaeast, westus)" + type = string +} + +# ── VM Configuration ──────────────────────────────────────── +variable "vm_name" { + description = "Name of the VM" + type = string + default = "openclaw-b2pts" +} + +variable "vm_size" { + description = "Azure VM SKU" + type = string + default = "Standard_B2pts_v2" +} + +variable "os_disk_size_gb" { + description = "OS disk size in GB" + type = number + default = 30 +} + +# ── VM OS Configuration ──────────────────────────────────── +variable "admin_username" { + description = "VM admin username" + type = string + default = "azureuser" +} + +variable "ssh_public_key_path" { + description = "Path to SSH public key (~/.ssh/id_rsa.pub)" + type = string + default = "~/.ssh/id_rsa.pub" +} + +# ── Network Security Group (NSG) ──────────────────────────── +variable "ssh_allowed_cidrs" { + description = "CIDR blocks allowed SSH access (port 22)" + type = list(string) + default = ["0.0.0.0/0", "::/0"] +} + +variable "gateway_allowed_cidrs" { + description = "CIDR blocks allowed OpenClaw gateway access (port 18789)" + type = list(string) + default = ["0.0.0.0/0", "::/0"] +} + +# ── Static Public IP ──────────────────────────────────────── +variable "public_ip_name" { + description = "Name of static public IP" + type = string + default = "openclaw-b2pts-public-ip" +} + +# ── OpenClaw Configuration ───────────────────────────────── +variable "swap_size" { + description = "Swap file size in GB" + type = number + default = 2 +} + +variable "openclaw_memory_limit_mb" { + description = "Hard memory limit for OpenClaw systemd service (MB)" + type = number + default = 800 +} + +# ── Secrets (from .env via .envrc) ───────────────────────── +variable "openrouter_api_key" { + sensitive = true +} + +variable "telegram_bot_token" { + sensitive = true +} + +variable "openclaw_gateway_token" { + sensitive = true +} + +variable "brave_api_key" { + description = "Brave Search API key (free tier: 1000 req/month). Leave empty to use DuckDuckGo fallback." + sensitive = true + default = "" +} + +variable "telegram_owner_id" { + description = "Your Telegram numeric user ID (get it from @userinfobot). Grants /model and other privileged commands." + default = "" +} + +variable "slack_app_token" { + description = "Slack App-Level Token for Socket Mode connection (starts with 'xapp-')" + sensitive = true + default = "" +} + +variable "slack_bot_token" { + description = "Slack Bot User OAuth Token for sending messages (starts with 'xoxb-')" + sensitive = true + default = "" +}