diff --git a/.github/SECURITY_DEPLOYMENT.md b/.github/SECURITY_DEPLOYMENT.md new file mode 100644 index 0000000..0c864ce --- /dev/null +++ b/.github/SECURITY_DEPLOYMENT.md @@ -0,0 +1,61 @@ +# Deployment Security + +## GitHub Actions Security Measures + +### Secret Protection + +1. **Fork PR Protection**: Secrets are NOT available to workflows triggered by pull requests from forks +2. **First-time Contributor Approval**: New contributors require manual approval before workflows run +3. **Secret Masking**: GitHub automatically masks secrets in logs + +### Our Additional Protections + +1. **Environment Protection**: The deployment workflow uses the `trunk` environment which can be configured with: + - Required reviewers + - Deployment branches restrictions + - Wait timer before deployment + +2. **Workflow Conditions**: The deploy workflow only runs when: + - CI passes on the main branch (not from PRs) + - OR manually triggered by authorized users + +3. **Branch Protection**: Ensure `main` branch has: + - Required PR reviews + - Required status checks (CI must pass) + - No direct pushes + +## Setting Up Environment Protection (Recommended) + +1. Go to Settings → Environments in your GitHub repository +2. Create a "trunk" environment +3. Configure protection rules: + - Add required reviewers + - Restrict deployment branches to `main` + - Add any required wait time + +4. Move your `FLY_API_TOKEN` secret to the trunk environment: + - Remove it from repository secrets + - Add it to the trunk environment secrets + +## Security Best Practices + +1. **Rotate API tokens regularly** +2. **Use least-privilege tokens** - Create Fly.io tokens with only necessary permissions +3. **Monitor deployments** - Set up notifications for production deployments +4. **Audit workflow changes** - Review any PR that modifies `.github/workflows/` + +## What Attackers Can't Do + +Even if an attacker: +- Opens a PR with modified workflows → No access to secrets +- Tries to echo secrets → GitHub masks them +- Modifies the workflow file → Environment protection blocks unauthorized deployments +- Gets their PR merged → Branch protection requires reviews + +## Emergency Response + +If you suspect compromise: +1. Immediately revoke the Fly.io API token: `fly auth revoke ` +2. Generate a new token: `fly auth token` +3. Update the GitHub secret +4. Review deployment logs for unauthorized activity \ No newline at end of file diff --git a/.github/workflows/deploy-fly.yaml b/.github/workflows/deploy-fly.yaml new file mode 100644 index 0000000..e29b574 --- /dev/null +++ b/.github/workflows/deploy-fly.yaml @@ -0,0 +1,38 @@ +name: Deploy to Fly.io + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + branches: ["main"] + workflow_dispatch: # Allow manual trigger + +jobs: + deploy: + name: Deploy to Fly.io + runs-on: ubuntu-latest + environment: + name: trunk + # Only deploy if: + # 1. CI workflow succeeded AND triggered by push to main (not from a PR) + # 2. OR manually triggered (workflow_dispatch) + # This prevents deployment from PRs even if someone modifies the workflow + if: | + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main') || + github.event_name == 'workflow_dispatch' + + steps: + - uses: actions/checkout@v4 + + - name: Setup Fly CLI + uses: superfly/flyctl-actions/setup-flyctl@master + + - name: Deploy to Fly.io + run: | + cd examples/deployments/fly.io + ./deploy.sh + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 7977756..8eb0011 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -64,6 +64,7 @@ jobs: runs-on: ubuntu-latest needs: [quality-checks, test] # Only build and push Docker images on main branch after tests pass + # Note: Fly.io deployment happens in a separate workflow (deploy-fly.yaml) if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 diff --git a/examples/deployments/fly.io/DEPLOYMENT.md b/examples/deployments/fly.io/DEPLOYMENT.md new file mode 100644 index 0000000..1f32bb4 --- /dev/null +++ b/examples/deployments/fly.io/DEPLOYMENT.md @@ -0,0 +1,53 @@ +# Fly.io Deployment + +This directory contains configuration for deploying Haystack to Fly.io. + +## Automatic Deployment + +The application is automatically deployed to Fly.io when changes are merged to the `main` branch via GitHub Actions. + +### Setup Requirements + +1. **Fly.io Account**: You need a Fly.io account with an app already created +2. **GitHub Secret**: Add your Fly.io API token as a GitHub secret named `FLY_API_TOKEN` + +### Getting Your Fly.io API Token + +```bash +fly auth token +``` + +### Manual Deployment + +To deploy manually from your local machine: + +```bash +cd examples/deployments/fly.io +fly deploy +``` + +### Monitoring + +Check deployment status: +```bash +fly status +fly logs +``` + +### Configuration + +The deployment configuration is in: +- `fly.toml` - Fly.io app configuration +- `Dockerfile` - Container definition +- `.github/workflows/deploy-fly.yaml` - GitHub Actions workflow + +### Environment Variables + +The following environment variables are configured in `fly.toml`: +- `HAYSTACK_ADDR`: Server binding address (fly-global-services:1337) +- `HAYSTACK_STORAGE`: Storage backend (memory) +- `HAYSTACK_LOG_LEVEL`: Logging level (debug/info/error/silent) + +### UDP Service + +The service runs on UDP port 1337 and is accessible via the Fly.io global anycast network. \ No newline at end of file diff --git a/examples/deployments/fly.io/fly.toml b/examples/deployments/fly.io/fly.toml index 43daedf..d8628d1 100644 --- a/examples/deployments/fly.io/fly.toml +++ b/examples/deployments/fly.io/fly.toml @@ -7,7 +7,7 @@ app = 'haystack-example-trunk' primary_region = 'dfw' [build] -image = 'nomasters/haystack:tree-TREE_HASH' +dockerfile = "Dockerfile" [deploy] strategy = 'immediate' diff --git a/server/server.go b/server/server.go index 62b0d0a..fb598de 100644 --- a/server/server.go +++ b/server/server.go @@ -122,8 +122,8 @@ func (s *Server) serve() { return default: if err := s.processPacket(); err != nil { - // In a production environment, you might want to log this - // but continue processing other packets + // Log the error and continue processing other packets + s.logger.Errorf("Failed to process packet: %v", err) continue } } @@ -158,15 +158,13 @@ func (s *Server) processPacket() error { switch n { case needle.HashLength: // GET operation: 32-byte hash query - s.logger.Infof("GET request from %s", addr) return s.handleGet(buf[:n], addr) case needle.NeedleLength: // SET operation: 192-byte needle storage - s.logger.Infof("SET request from %s", addr) return s.handleSet(buf[:n], addr) default: // Invalid packet size, log and drop - s.logger.Debugf("Invalid packet size %d from %s", n, addr) + s.logger.Debugf("Invalid packet size %d", n) return nil } } @@ -177,21 +175,21 @@ func (s *Server) handleGet(hashBytes []byte, addr net.Addr) error { var hash needle.Hash copy(hash[:], hashBytes) + // Log the GET request with hash + s.logger.Infof("GET request for hash %x", hash) + // Retrieve needle from storage n, err := s.storage.Get(hash) if err != nil { - s.logger.Debugf("GET failed for hash %x: %v", hash, err) - return fmt.Errorf("failed to get needle: %w", err) + return fmt.Errorf("GET failed to hash %x: %w", hash, err) } // Send the full needle as response - bytesWritten, err := s.conn.WriteTo(n.Bytes(), addr) - if err != nil { - s.logger.Errorf("Failed to send GET response to %s: %v", addr, err) + if _, err := s.conn.WriteTo(n.Bytes(), addr); err != nil { + s.logger.Errorf("Failed to send GET response for hash %x: %v", hash, err) return err } - - s.logger.Debugf("GET response sent to %s (%d bytes)", addr, bytesWritten) + s.logger.Debugf("GET successful for hash %x", hash) return nil } @@ -204,11 +202,17 @@ func (s *Server) handleSet(needleBytes []byte, _ net.Addr) error { return fmt.Errorf("invalid needle: %w", err) } + // Get the hash for logging + hash := n.Hash() + s.logger.Infof("SET request for hash %x", hash) + // Store the needle if err := s.storage.Set(n); err != nil { - return fmt.Errorf("failed to store needle: %w", err) + return fmt.Errorf("SET failed to store needle for hash %x: %w", hash, err) } + s.logger.Debugf("SET successful for hash %x", hash) + // No response for SET operations (by design) return nil }