diff --git a/.env.example b/.env.example index f313082..32c16a8 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,36 @@ # Z2api Go Configuration +# ---------------------------------------------------------------------------- # Upstream API Configuration +# ---------------------------------------------------------------------------- +# Z.ai API Token (server-side - never exposed to clients) +# Get your token from: https://chat.z.ai TOKEN= +# ---------------------------------------------------------------------------- +# Proxy Authentication (Optional but Recommended) +# ---------------------------------------------------------------------------- +# Proxy API Keys - client-facing authentication separate from Z.ai token +# Format: key1:name1,key2:name2,key3:name3 +# +# Example with multiple keys for different teams/services: +# PROXY_API_KEYS=sk-dev-team:Development Team,sk-prod-service:Production Service,sk-testing:QA Testing +# +# How clients use it: +# Authorization: Bearer sk-dev-team +# +# Benefits: +# - Your Z.ai token stays server-side and is never exposed +# - Track usage per key/team via GET /admin/usage +# - Revoke individual keys without affecting others +# - Different access levels for different services +# +# Leave empty to disable authentication (open access - not recommended for production) +PROXY_API_KEYS= + +# ---------------------------------------------------------------------------- # API Server Configuration +# ---------------------------------------------------------------------------- PORT=8080 DEBUG=false DEBUG_MSG=false @@ -13,4 +40,25 @@ DEBUG_MSG=false THINK_TAGS_MODE=reasoning # Default Model -MODEL=glm-4.7 \ No newline at end of file +MODEL=glm-4.7 + +# ============================================================================ +# Tailscale Configuration +# ============================================================================ + +# Required: Tailscale ephemeral auth key (generate from Tailscale admin panel) +# Generate at: https://login.tailscale.com/admin/settings/keys +# Recommended: Use ephemeral keys for enhanced security +# Example: tskey-auth-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TS_AUTH_KEY= + +# Optional: Hostname to use on the Tailscale network +# Default: z2api-go +TS_HOSTNAME=z2api-go + +# Optional: Additional Tailscale arguments +# Examples: +# - Advertise tags: --advertise-tags=tag:service,tag:production +# - Accept DNS: --accept-dns=true +# - Advertise routes: --advertise-routes=10.0.0.0/24 +TS_EXTRA_ARGS= diff --git a/.gitignore b/.gitignore index f7a48f4..6dc5dbc 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ go.work.sum # env file .env +# Docker override (user-specific configuration) +docker-compose.override.yml + # Editor/IDE # .idea/ # .vscode/ \ No newline at end of file diff --git a/Dockerfile.tailscale b/Dockerfile.tailscale new file mode 100644 index 0000000..af42b88 --- /dev/null +++ b/Dockerfile.tailscale @@ -0,0 +1,9 @@ +# Tailscale sidecar container +FROM tailscale/tailscale:stable + +# Copy tailscale startup script +COPY docker/tailscale-entrypoint.sh /usr/local/bin/tailscale-entrypoint.sh +RUN chmod +x /usr/local/bin/tailscale-entrypoint.sh + +# Set the entrypoint +ENTRYPOINT ["/usr/local/bin/tailscale-entrypoint.sh"] diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..40d4dd9 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,196 @@ +# Quick Start Guide: Tailscale + z2api-go + +Get up and running with Tailscale in 5 minutes. + +## Prerequisites + +- Docker and Docker Compose installed +- A Tailscale account (free tier works great!) + +## Step-by-Step Setup + +### 1. Clone the Repository + +```bash +git clone https://github.com/Tylerx404/z2api-go.git +cd z2api-go +``` + +### 2. Generate Tailscale Auth Key + +1. Visit: https://login.tailscale.com/admin/settings/keys +2. Click **"Generate auth key"** +3. Configure: + - ✅ Check **"Ephemeral"** (recommended for containers) + - ✅ Check **"Reusable"** (optional, useful for testing) + - Set expiration: 90 days +4. Copy the key (starts with `tskey-auth-`) + +### 3. Configure Environment + +```bash +# Copy the example environment file +cp .env.example .env + +# Edit .env and add your Tailscale auth key +nano .env # or use your preferred editor +``` + +Add your key: +```env +TS_AUTH_KEY=tskey-auth-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +### 4. Start the Services + +```bash +docker-compose up -d +``` + +### 5. Verify Connection + +```bash +# Check Tailscale logs +docker logs z2api-go-tailscale + +# You should see: "✓ Tailscale connected successfully!" +``` + +### 6. Test the API + +From any device on your Tailscale network: + +```bash +# Health check +curl http://z2api-go:8080/health + +# List available models +curl http://z2api-go:8080/v1/models + +# Test chat completion +curl http://z2api-go:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "glm-4.7", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +## Success! 🎉 + +Your z2api-go service is now: +- ✅ Running on your private Tailscale network +- ✅ Accessible from any of your Tailscale devices +- ✅ Using ephemeral authentication (auto-cleanup) +- ✅ Encrypted with WireGuard + +## Next Steps + +### Customize Hostname + +Want a custom name instead of `z2api-go`? + +```env +TS_HOSTNAME=my-custom-api +``` + +Access via: `http://my-custom-api:8080` + +### Add Tags for ACL + +Control access with Tailscale ACLs: + +```env +TS_EXTRA_ARGS=--advertise-tags=tag:service,tag:production +``` + +### Access from Mobile + +1. Install Tailscale on your phone +2. Connect to your tailnet +3. Access the API: `http://z2api-go:8080` + +### Local Development + +Want to develop locally without Tailscale? + +```bash +docker-compose -f docker-compose.local.yml up -d +``` + +Access via: `http://localhost:8080` + +## Troubleshooting + +### "TS_AUTH_KEY is required" Error + +Make sure your `.env` file contains: +```env +TS_AUTH_KEY=tskey-auth-... +``` + +### Can't Connect to API + +1. Verify Tailscale is running: + ```bash + docker ps | grep tailscale + ``` + +2. Check Tailscale status: + ```bash + docker exec z2api-go-tailscale tailscale status + ``` + +3. Make sure you're connected to Tailscale on your client device + +### Need Help? + +- See [TAILSCALE.md](TAILSCALE.md) for detailed documentation +- See [README.md](README.md) for general information +- Check [Tailscale Documentation](https://tailscale.com/kb/) + +## What's Happening? + +``` +Your Device (on Tailscale) + ↓ + [Tailscale Network] + ↓ + z2api-go Container + (via Tailscale sidecar) + ↓ + Z.ai API +``` + +- **Tailscale sidecar**: Manages VPN connection +- **z2api-go**: Shares Tailscale's network, visible on your tailnet +- **Ephemeral key**: Node auto-removes when stopped (clean!) + +## Common Commands + +```bash +# View logs +docker logs z2api-go-tailscale +docker logs z2api-go + +# Restart services +docker-compose restart + +# Stop services +docker-compose down + +# Rebuild after changes +docker-compose up -d --build + +# Check Tailscale connection +docker exec z2api-go-tailscale tailscale status + +# Check Tailscale IP +docker exec z2api-go-tailscale tailscale ip +``` + +--- + +**Happy coding!** 🚀 + +For more details, see [TAILSCALE.md](TAILSCALE.md) diff --git a/README.md b/README.md index d34d2c3..1774ccd 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ Proxy API for Z.ai compatible with OpenAI and Anthropic, written in Go. +## Features + +- ✅ OpenAI-compatible API endpoints +- ✅ Anthropic-compatible API endpoints +- ✅ First-class Tailscale integration with ephemeral auth keys +- ✅ Docker and docker-compose support +- ✅ Multiple deployment modes (local, Tailscale) + ## Installation ### Using Go @@ -13,14 +21,38 @@ go mod download go run main.go ``` -### Using Docker +### Using Docker (Local Development) + +For local development without Tailscale: + +```bash +git clone https://github.com/Tylerx404/z2api-go.git +cd z2api-go +docker-compose -f docker-compose.local.yml up -d +``` + +The API will be available at `http://localhost:8080`. + +### Using Docker with Tailscale (Recommended for Production) + +For production deployments with secure Tailscale networking: ```bash git clone https://github.com/Tylerx404/z2api-go.git cd z2api-go + +# Copy and configure environment variables +cp .env.example .env + +# Generate a Tailscale ephemeral auth key (see below) +# Add it to your .env file as TS_AUTH_KEY + +# Start services docker-compose up -d ``` +The API will be available on your Tailscale network at `http://z2api-go:8080` (or your custom hostname). + ## Configuration Copy the `.env.example` file to `.env` and edit: @@ -40,6 +72,116 @@ cp .env.example .env | `THINK_TAGS_MODE` | Thinking tags processing mode (`reasoning`, `think`, `strip`, `details`) | `reasoning` | | `MODEL` | Default model | `glm-4.7` | +### Tailscale Configuration + +| Variable | Description | Required | Default | +|----------|-------------|----------|---------| +| `TS_AUTH_KEY` | Tailscale ephemeral auth key | **Yes** | - | +| `TS_HOSTNAME` | Hostname on Tailscale network | No | `z2api-go` | +| `TS_EXTRA_ARGS` | Additional Tailscale arguments | No | - | + +## Tailscale Setup + +### Why Tailscale? + +Tailscale provides secure, zero-config VPN networking for your services: +- 🔒 **Secure**: WireGuard-based encryption +- 🚀 **Fast**: Direct peer-to-peer connections +- 🎯 **Simple**: No complex firewall rules or port forwarding +- 🔑 **Ephemeral Keys**: Enhanced security with temporary authentication + +### Generating an Ephemeral Auth Key + +1. Go to [Tailscale Admin Console → Settings → Keys](https://login.tailscale.com/admin/settings/keys) +2. Click **Generate auth key** +3. Configure the key: + - ✅ **Check "Ephemeral"** - Key is temporary and node auto-removes when offline + - ✅ **Check "Reusable"** (optional) - Allow multiple uses of the same key + - Set expiration (e.g., 90 days) + - Add tags (optional, e.g., `tag:service`, `tag:api`) +4. Copy the generated key (format: `tskey-auth-xxxxx-xxxxxx`) +5. Add it to your `.env` file: + ``` + TS_AUTH_KEY=tskey-auth-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + ``` + +### Ephemeral vs Regular Keys + +**Ephemeral Keys (Recommended):** +- ✅ Nodes automatically removed when disconnected +- ✅ Better security posture +- ✅ Ideal for containerized/temporary workloads +- ✅ No manual cleanup needed + +**Regular Keys:** +- Nodes persist even when offline +- Require manual removal from admin console +- Better for permanent infrastructure + +### Advanced Tailscale Configuration + +You can customize Tailscale behavior using `TS_EXTRA_ARGS`: + +```bash +# Advertise tags for ACL rules +TS_EXTRA_ARGS=--advertise-tags=tag:service,tag:production + +# Accept DNS configuration from Tailscale +TS_EXTRA_ARGS=--accept-dns=true + +# Advertise routes to other networks +TS_EXTRA_ARGS=--advertise-routes=10.0.0.0/24 + +# Multiple arguments +TS_EXTRA_ARGS=--advertise-tags=tag:api --accept-dns=true +``` + +### Architecture + +The Tailscale integration uses a sidecar pattern: + +``` +┌─────────────────────────────────────┐ +│ Docker Compose │ +│ ┌─────────────────────────────┐ │ +│ │ Tailscale Container │ │ +│ │ - Manages VPN connection │ │ +│ │ - Network namespace │ │ +│ └─────────────────────────────┘ │ +│ ↕ (shares network) │ +│ ┌─────────────────────────────┐ │ +│ │ Z2api-go Container │ │ +│ │ - API service │ │ +│ │ - Uses Tailscale network │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +**Benefits:** +- Clean separation of concerns +- Easy to enable/disable Tailscale +- Reusable across different services +- Follows Docker best practices + +### Accessing Your API + +Once deployed with Tailscale: + +1. From any device on your Tailscale network: + ```bash + curl http://z2api-go:8080/health + ``` + +2. Use the Tailscale IP (check with `tailscale status`): + ```bash + curl http://100.x.y.z:8080/v1/models + ``` + +3. Use MagicDNS name: + ```bash + curl http://z2api-go.your-tailnet.ts.net:8080/v1/chat/completions + ``` + ## License MIT License \ No newline at end of file diff --git a/TAILSCALE.md b/TAILSCALE.md new file mode 100644 index 0000000..501ba75 --- /dev/null +++ b/TAILSCALE.md @@ -0,0 +1,331 @@ +# Tailscale Integration Guide + +This document provides detailed information about the Tailscale integration in z2api-go. + +## Table of Contents + +- [Overview](#overview) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [Security Best Practices](#security-best-practices) +- [Troubleshooting](#troubleshooting) +- [Advanced Topics](#advanced-topics) + +## Overview + +Z2api-go includes first-class Tailscale support for secure, private networking. This integration: + +- Uses **ephemeral auth keys** for enhanced security +- Implements Docker **sidecar pattern** for clean architecture +- Provides **zero-config networking** once Tailscale is set up +- Supports **reusable configuration** across deployments + +## Quick Start + +### 1. Generate a Tailscale Auth Key + +Visit: https://login.tailscale.com/admin/settings/keys + +Create a new auth key with: +- ✅ **Ephemeral** enabled (recommended) +- ✅ **Reusable** enabled (optional, useful for testing) +- Expiration: 90 days (or your preference) +- Tags: `tag:service` or `tag:api` (optional, for ACLs) + +### 2. Configure Environment + +```bash +cp .env.example .env +``` + +Edit `.env` and add your auth key: +```env +TS_AUTH_KEY=tskey-auth-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TS_HOSTNAME=z2api-go +``` + +### 3. Deploy + +```bash +docker-compose up -d +``` + +### 4. Verify Connection + +```bash +# Check Tailscale logs +docker logs z2api-go-tailscale + +# Test API access via Tailscale +curl http://z2api-go:8080/health +``` + +## Configuration + +### Required Variables + +#### `TS_AUTH_KEY` +Your Tailscale authentication key. + +**Format:** `tskey-auth-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` + +**Security:** +- Use ephemeral keys when possible +- Rotate keys regularly +- Never commit keys to version control + +### Optional Variables + +#### `TS_HOSTNAME` +Custom hostname for your service on the Tailscale network. + +**Default:** `z2api-go` + +**Example:** +```env +TS_HOSTNAME=z2api-production +``` + +**Access:** +- Via hostname: `http://z2api-production:8080` +- Via MagicDNS: `http://z2api-production.your-tailnet.ts.net:8080` + +#### `TS_EXTRA_ARGS` +Additional arguments passed to `tailscale up`. + +**Common uses:** + +1. **Advertise tags** (for ACL management): + ```env + TS_EXTRA_ARGS=--advertise-tags=tag:service,tag:production + ``` + +2. **Accept DNS from Tailscale**: + ```env + TS_EXTRA_ARGS=--accept-dns=true + ``` + +3. **Advertise subnet routes**: + ```env + TS_EXTRA_ARGS=--advertise-routes=10.0.0.0/24 + ``` + +4. **Multiple arguments**: + ```env + TS_EXTRA_ARGS=--advertise-tags=tag:api --accept-dns=true --ssh + ``` + +## Security Best Practices + +### 1. Use Ephemeral Keys + +Ephemeral keys ensure nodes are automatically removed when disconnected: + +``` +✅ Ephemeral: Node auto-removes after disconnect +❌ Regular: Node persists, requires manual cleanup +``` + +### 2. Use Reusable Keys Carefully + +Reusable keys can be used multiple times but should be: +- Rotated regularly +- Used only in trusted environments +- Combined with ephemeral mode + +### 3. Implement ACL Rules + +Use Tailscale ACLs to control access: + +```json +{ + "tagOwners": { + "tag:service": ["autogroup:admin"], + "tag:api": ["autogroup:admin"] + }, + "acls": [ + { + "action": "accept", + "src": ["autogroup:member"], + "dst": ["tag:api:8080"] + } + ] +} +``` + +### 4. Never Commit Auth Keys + +Always use: +- Environment variables +- Docker secrets +- Secret management services + +Never: +- Commit `.env` files with real keys +- Hardcode keys in Dockerfiles +- Share keys in public repositories + +### 5. Rotate Keys Regularly + +Set appropriate expiration times: +- Development: 30-90 days +- Production: 30 days +- CI/CD: 1-7 days + +## Troubleshooting + +### Service Won't Start + +**Symptom:** Container exits immediately + +**Check:** +```bash +docker logs z2api-go-tailscale +``` + +**Common causes:** +1. Missing `TS_AUTH_KEY` + - Solution: Add key to `.env` file +2. Invalid auth key + - Solution: Generate new key from Tailscale admin +3. Network permissions + - Solution: Ensure `NET_ADMIN` and `NET_RAW` capabilities + +### Can't Access API + +**Symptom:** Connection refused or timeout + +**Check:** +1. Verify Tailscale connection: + ```bash + docker exec z2api-go-tailscale tailscale status + ``` + +2. Check if service is running: + ```bash + docker ps | grep z2api-go + ``` + +3. Verify network mode: + ```bash + docker inspect z2api-go | grep NetworkMode + # Should show: "service:z2api-go-tailscale" + ``` + +4. Test from Tailscale network: + ```bash + tailscale ping z2api-go + curl http://z2api-go:8080/health + ``` + +### Permission Denied Errors + +**Symptom:** `operation not permitted` or similar + +**Solution:** Ensure proper Docker capabilities: +```yaml +cap_add: + - NET_ADMIN + - NET_RAW +``` + +### Hostname Conflicts + +**Symptom:** Hostname already taken + +**Solution:** Choose a unique hostname: +```env +TS_HOSTNAME=z2api-go-prod-01 +``` + +## Advanced Topics + +### Using with Docker Swarm + +For Docker Swarm deployments: + +```yaml +version: "3.8" +services: + tailscale: + image: z2api-go-tailscale + deploy: + mode: global + cap_add: + - NET_ADMIN + - NET_RAW + environment: + - TS_AUTH_KEY=${TS_AUTH_KEY} + - TS_HOSTNAME=${TS_HOSTNAME} +``` + +### Using with Kubernetes + +Consider using the [Tailscale Kubernetes Operator](https://tailscale.com/kb/1236/kubernetes-operator) instead of the sidecar pattern. + +### Multiple Services Behind Tailscale + +Share Tailscale network across multiple services: + +```yaml +services: + tailscale: + # ... tailscale config ... + + z2api-go: + network_mode: "service:tailscale" + # ... + + another-service: + network_mode: "service:tailscale" + # ... +``` + +### Custom Tailscale Version + +Pin specific Tailscale version: + +```dockerfile +FROM tailscale/tailscale:v1.56.1 +``` + +### Monitoring + +Monitor Tailscale connection: + +```bash +# View status +docker exec z2api-go-tailscale tailscale status + +# View network info +docker exec z2api-go-tailscale tailscale netcheck + +# View logs +docker logs -f z2api-go-tailscale +``` + +### Backup and Recovery + +Tailscale state is persisted in the `tailscale-state` volume: + +```bash +# Backup state +docker run --rm -v z2api-go_tailscale-state:/data -v $(pwd):/backup alpine tar czf /backup/tailscale-state.tar.gz /data + +# Restore state +docker run --rm -v z2api-go_tailscale-state:/data -v $(pwd):/backup alpine tar xzf /backup/tailscale-state.tar.gz -C / +``` + +## References + +- [Tailscale Documentation](https://tailscale.com/kb/) +- [Tailscale + Docker Best Practices](https://tailscale.com/kb/1282/docker) +- [Ephemeral Nodes](https://tailscale.com/kb/1111/ephemeral-nodes) +- [ACL Documentation](https://tailscale.com/kb/1018/acls) +- [Auth Keys](https://tailscale.com/kb/1085/auth-keys) + +## Support + +For issues specific to: +- **Tailscale integration**: Open an issue in this repository +- **Tailscale service**: Visit [Tailscale Support](https://tailscale.com/contact/support) diff --git a/config/config.go b/config/config.go index 80901f0..8e4f19a 100644 --- a/config/config.go +++ b/config/config.go @@ -23,6 +23,7 @@ type APIConfig struct { DebugMsg bool Think string Anonymous bool + ProxyKeys map[string]string // proxy API key -> name/description } // ModelConfig holds model configuration @@ -64,6 +65,7 @@ func loadConfig() *Config { Debug: getEnvBool("DEBUG", false), DebugMsg: getEnvBool("DEBUG_MSG", false), Think: getEnv("THINK_TAGS_MODE", "reasoning"), + ProxyKeys: parseProxyKeys(getEnv("PROXY_API_KEYS", "")), }, Model: ModelConfig{ Default: getEnv("MODEL", "glm-4.7"), @@ -149,4 +151,36 @@ func getEnvBool(key string, defaultValue bool) bool { return strings.ToLower(value) == "true" } return defaultValue +} + +// parseProxyKeys parses proxy API keys from format "key1:name1,key2:name2" +func parseProxyKeys(value string) map[string]string { + result := make(map[string]string) + if value == "" { + return result + } + + pairs := strings.Split(value, ",") + for _, pair := range pairs { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + parts := strings.SplitN(pair, ":", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + name := strings.TrimSpace(parts[1]) + if key != "" && name != "" { + result[key] = name + } + } else if len(parts) == 1 { + // If only key provided, use "unnamed" as name + key := strings.TrimSpace(parts[0]) + if key != "" { + result[key] = "unnamed" + } + } + } + + return result } \ No newline at end of file diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..20e1764 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,18 @@ +# Local development docker-compose without Tailscale +# Use this for local testing: docker-compose -f docker-compose.local.yml up +services: + z2api-go: + build: . + image: z2api-go + container_name: z2api-go + ports: + - "8080:8080" + environment: + - TOKEN=125a583265464446be50412ef8701d72.tBGGNJmAQMvOhvXO + - PROXY_API_KEYS=sk-internal-dev:Internal Dev Team,sk-prod-service:Production Service + - PORT=8080 + - DEBUG=false + - DEBUG_MSG=false + - THINK_TAGS_MODE=reasoning + - MODEL=glm-4.7 + restart: unless-stopped diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example new file mode 100644 index 0000000..09028da --- /dev/null +++ b/docker-compose.override.yml.example @@ -0,0 +1,30 @@ +# Optional docker-compose override file +# Use this if you want to expose ports on localhost in addition to Tailscale +# +# Usage: +# 1. Copy this file: cp docker-compose.override.yml.example docker-compose.override.yml +# 2. Uncomment the configuration you need +# 3. Run: docker-compose up -d +# +# Note: docker-compose automatically loads docker-compose.override.yml if it exists + +services: + # Option 1: Expose ports on host by removing network_mode + # This makes the service accessible both via Tailscale AND localhost + # Uncomment the 'ports' section below: + # tailscale: + # ports: + # - "${PORT:-8080}:${PORT:-8080}" + + # Option 2: Run without Tailscale (local development only) + # This completely disables Tailscale integration + # Uncomment both sections below: + # z2api-go: + # network_mode: bridge + # ports: + # - "${PORT:-8080}:${PORT:-8080}" + # depends_on: [] + # + # tailscale: + # profiles: + # - disabled diff --git a/docker-compose.yml b/docker-compose.yml index a7089e0..f1cf72a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,51 @@ services: + # Tailscale sidecar service + tailscale: + build: + context: . + dockerfile: Dockerfile.tailscale + image: z2api-go-tailscale + container_name: z2api-go-tailscale + hostname: ${TS_HOSTNAME:-z2api-go} + environment: + # Required: Tailscale ephemeral auth key + # Generate at: https://login.tailscale.com/admin/settings/keys + - TS_AUTH_KEY=${TS_AUTH_KEY} + # Optional: Customize hostname on Tailscale network + - TS_HOSTNAME=${TS_HOSTNAME:-z2api-go} + # Optional: Additional Tailscale arguments (e.g., --advertise-tags=tag:service) + - TS_EXTRA_ARGS=${TS_EXTRA_ARGS:-} + # Optional: State directory for Tailscale + - TS_STATE_DIR=/var/lib/tailscale + volumes: + # Persist Tailscale state (optional, but recommended for non-ephemeral setups) + - tailscale-state:/var/lib/tailscale + # Tailscale socket directory + - tailscale-sock:/var/run/tailscale + cap_add: + - NET_ADMIN + - NET_RAW + restart: unless-stopped + + # Main application service z2api-go: build: . image: z2api-go container_name: z2api-go - ports: - - "8080:8080" + # Share Tailscale's network namespace for seamless integration + network_mode: "service:tailscale" environment: - - TOKEN= + - TOKEN=125a583265464446be50412ef8701d72.tBGGNJmAQMvOhvXO + - PROXY_API_KEYS=sk-internal-dev:Internal Dev Team,sk-prod-service:Production Service - PORT=8080 - DEBUG=false - DEBUG_MSG=false - THINK_TAGS_MODE=reasoning - MODEL=glm-4.7 - restart: unless-stopped \ No newline at end of file + depends_on: + - tailscale + restart: unless-stopped + +volumes: + tailscale-state: + tailscale-sock: \ No newline at end of file diff --git a/docker/tailscale-entrypoint.sh b/docker/tailscale-entrypoint.sh new file mode 100644 index 0000000..7fe5b3b --- /dev/null +++ b/docker/tailscale-entrypoint.sh @@ -0,0 +1,94 @@ +#!/bin/sh +set -e + +# Check if TS_AUTH_KEY is set +if [ -z "${TS_AUTH_KEY}" ]; then + echo "ERROR: TS_AUTH_KEY environment variable is required" + echo "Please generate an ephemeral auth key from: https://login.tailscale.com/admin/settings/keys" + exit 1 +fi + +# Set default values if not provided +TS_HOSTNAME="${TS_HOSTNAME:-z2api-go}" +TS_EXTRA_ARGS="${TS_EXTRA_ARGS:-}" +TS_STATE_DIR="${TS_STATE_DIR:-/var/lib/tailscale}" + +# Create state directory if it doesn't exist +mkdir -p "${TS_STATE_DIR}" + +echo "Starting Tailscale daemon..." +tailscaled --state="${TS_STATE_DIR}/tailscaled.state" --socket=/var/run/tailscale/tailscaled.sock & +TAILSCALED_PID=$! + +# Wait for tailscaled to start with retry logic +echo "Waiting for tailscaled to be ready..." +RETRY_COUNT=0 +MAX_RETRIES=30 +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if tailscale status >/dev/null 2>&1; then + echo "✓ Tailscaled is ready" + break + fi + RETRY_COUNT=$((RETRY_COUNT + 1)) + sleep 1 +done + +if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo "ERROR: Tailscaled failed to start within ${MAX_RETRIES} seconds" + exit 1 +fi + +echo "Connecting to Tailscale network..." +echo " Hostname: ${TS_HOSTNAME}" +echo " State Dir: ${TS_STATE_DIR}" + +# Validate TS_EXTRA_ARGS if provided using whitelist approach +if [ -n "${TS_EXTRA_ARGS}" ]; then + # Validate that TS_EXTRA_ARGS contains only properly formatted Tailscale flags + # Pattern explanation: + # - Starts with optional whitespace + # - One or more flags starting with -- followed by alphanumeric/hyphen/underscore + # - Optional =value with safe characters (alphanumeric, hyphen, underscore, comma, dot, colon, slash) + # - Flags separated by spaces + # This strict pattern prevents injection while allowing legitimate Tailscale flags + if ! echo "${TS_EXTRA_ARGS}" | grep -qE '^[[:space:]]*(--[a-zA-Z0-9_-]+(=[a-zA-Z0-9._,:/\\-]+)?[[:space:]]*)+$'; then + echo "ERROR: TS_EXTRA_ARGS contains invalid format" + echo "Only properly formatted Tailscale flags are allowed." + echo "Format: --flag or --flag=value" + echo "Allowed characters in flag names: alphanumeric, hyphen, underscore" + echo "Allowed characters in values: alphanumeric, hyphen, underscore, comma, dot, colon, slash" + echo "" + echo "Examples of valid usage:" + echo " --accept-dns=true" + echo " --advertise-tags=tag:my-service,tag:production" + echo " --advertise-routes=10.0.0.0/24" + echo " --accept-dns=true --ssh" + exit 1 + fi + echo " Extra Args: ${TS_EXTRA_ARGS}" +fi + +# Authenticate with Tailscale using ephemeral key +# Note: TS_EXTRA_ARGS is intentionally not quoted to allow word splitting +# for multiple arguments. The variable is validated above using a whitelist +# of safe characters to prevent injection attacks. +tailscale up \ + --authkey="${TS_AUTH_KEY}" \ + --hostname="${TS_HOSTNAME}" \ + --accept-routes \ + ${TS_EXTRA_ARGS} + +echo "✓ Tailscale connected successfully!" +echo " Status:" +tailscale status + +# Monitor tailscaled process +# Exit if daemon crashes - Docker will restart the container if restart policy is set +while true; do + if ! kill -0 $TAILSCALED_PID 2>/dev/null; then + echo "ERROR: Tailscaled process (PID $TAILSCALED_PID) has crashed" + echo "Container will exit and restart if restart policy is configured" + exit 1 + fi + sleep 10 +done diff --git a/handlers/usage.go b/handlers/usage.go new file mode 100644 index 0000000..eb34d94 --- /dev/null +++ b/handlers/usage.go @@ -0,0 +1,31 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/Tylerx404/z2api-go/middleware" +) + +// UsageHandler returns usage statistics for proxy API keys +func UsageHandler(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthMiddleware() + + // Only allow if auth is enabled (admin endpoint) + if !auth.GetRequireAuth() { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte(`{"error":"Proxy authentication is not enabled"}`)) + return + } + + usage := auth.GetUsage() + + response := map[string]interface{}{ + "total_keys": len(usage), + "keys": usage, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/main.go b/main.go index c761c83..2d5e56c 100644 --- a/main.go +++ b/main.go @@ -29,9 +29,11 @@ func main() { mux.HandleFunc("/v1/models", handlers.ModelsHandler) mux.HandleFunc("/v1/chat/completions", handlers.ChatCompletions) mux.HandleFunc("/v1/messages", handlers.AnthropicMessages) + mux.HandleFunc("/admin/usage", handlers.UsageHandler) - // Apply CORS middleware - handler := middleware.CORS(mux) + // Apply auth middleware then CORS + auth := middleware.GetAuthMiddleware() + handler := middleware.CORS(auth.AuthHandler(mux)) // Print startup info log.Println("---------------------------------------------------------------------") @@ -44,12 +46,20 @@ func main() { log.Printf("Anonymous Mode: %v", cfg.API.Anonymous) log.Printf("Debug Mode: %v", cfg.API.Debug) log.Printf("Debug Messages: %v", cfg.API.DebugMsg) + if auth.GetRequireAuth() { + log.Printf("Proxy Auth: ENABLED (%d keys configured)", len(cfg.API.ProxyKeys)) + } else { + log.Printf("Proxy Auth: DISABLED (open access)") + } log.Println("---------------------------------------------------------------------") log.Println("Available Endpoints:") - log.Println(" GET /health - Health check") + log.Println(" GET /health - Health check (no auth)") log.Println(" GET /v1/models - List models") log.Println(" POST /v1/chat/completions - OpenAI chat completions") log.Println(" POST /v1/messages - Anthropic messages") + if auth.GetRequireAuth() { + log.Println(" GET /admin/usage - Usage stats (requires proxy key)") + } log.Println("---------------------------------------------------------------------") // Start server diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..973f47c --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,137 @@ +package middleware + +import ( + "log" + "net/http" + "strings" + "sync" + "time" + + "github.com/Tylerx404/z2api-go/config" +) + +// UsageStats tracks usage per API key +type UsageStats struct { + TotalRequests int64 + LastUsed time.Time +} + +// AuthMiddleware handles proxy API key authentication +type AuthMiddleware struct { + keys map[string]string // Key -> Name/Description + usage map[string]*UsageStats + usageMutex sync.RWMutex + requireAuth bool +} + +var ( + authMiddleware *AuthMiddleware + authOnce sync.Once +) + +// GetAuthMiddleware returns the singleton auth middleware instance +func GetAuthMiddleware() *AuthMiddleware { + authOnce.Do(func() { + cfg := config.GetConfig() + authMiddleware = &AuthMiddleware{ + keys: cfg.API.ProxyKeys, + usage: make(map[string]*UsageStats), + requireAuth: len(cfg.API.ProxyKeys) > 0, + } + }) + return authMiddleware +} + +// Authenticate validates the proxy API key and returns key name if valid +func (am *AuthMiddleware) Authenticate(key string) (string, bool) { + if !am.requireAuth { + return "anonymous", true + } + + if name, ok := am.keys[key]; ok { + return name, true + } + + return "", false +} + +// RecordUsage records a request for an API key +func (am *AuthMiddleware) RecordUsage(keyName string) { + am.usageMutex.Lock() + defer am.usageMutex.Unlock() + + if am.usage[keyName] == nil { + am.usage[keyName] = &UsageStats{} + } + + stats := am.usage[keyName] + stats.TotalRequests++ + stats.LastUsed = time.Now() +} + +// GetUsage returns usage stats for all keys +func (am *AuthMiddleware) GetUsage() map[string]*UsageStats { + am.usageMutex.RLock() + defer am.usageMutex.RUnlock() + + // Return a copy to avoid concurrent access issues + result := make(map[string]*UsageStats, len(am.usage)) + for k, v := range am.usage { + result[k] = &UsageStats{ + TotalRequests: v.TotalRequests, + LastUsed: v.LastUsed, + } + } + return result +} + +// AuthHandler creates a middleware that validates proxy API keys +func (am *AuthMiddleware) AuthHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip auth for health endpoint + if r.URL.Path == "/health" { + next.ServeHTTP(w, r) + return + } + + // Extract API key from Authorization header + authHeader := r.Header.Get("Authorization") + var apiKey string + + if authHeader != "" { + // Support "Bearer " format + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { + apiKey = strings.TrimSpace(parts[1]) + } else { + // Also support raw key in header + apiKey = strings.TrimSpace(authHeader) + } + } + + // Authenticate + keyName, valid := am.Authenticate(apiKey) + if !valid { + log.Printf("[AUTH] Failed attempt from %s - missing or invalid API key", r.RemoteAddr) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":{"message":"Invalid or missing proxy API key","type":"authentication_error"}}`)) + return + } + + // Record usage + am.RecordUsage(keyName) + + if am.requireAuth { + log.Printf("[AUTH] %s - %s %s - key: %s", r.RemoteAddr, r.Method, r.URL.Path, keyName) + } + + // Call next handler + next.ServeHTTP(w, r) + }) +} + +// GetRequireAuth returns whether authentication is required +func (am *AuthMiddleware) GetRequireAuth() bool { + return am.requireAuth +}