Open Post – Test APIs Without Leaving VS Code

Open Post: Test APIs Without Leaving VS Code

If you’re tired of switching between your code editor and Postman, or concerned about your API credentials living in the cloud, I built something you might like.

Open Post is a fully offline REST & GraphQL API client that lives inside VS Code. No accounts, no cloud sync, no telemetry. Just you, your editor, and your APIs.

Why I Built This

I got tired of the constant context-switching. Write code in VS Code, test the API in Postman, copy the cURL command, switch back to VS Code, paste it somewhere… you know the drill.

Plus, I never felt comfortable with my API keys and tokens syncing to someone else’s cloud. Call me paranoid, but I prefer my secrets to stay on my machine.

So I built Open Post with three goals:

  1. Stay in VS Code — no more context-switching
  2. Privacy first — everything stays local
  3. Git-friendly — plain JSON files you can commit

Getting Started

Install from VS Code Marketplace:

https://marketplace.visualstudio.com/items?itemName=open-post.open-post

Step-by-step installation:

  1. Open VS Code
  2. Click the Extensions icon in the sidebar (or press Cmd+Shift+X / Ctrl+Shift+X)
  3. Search for “Open Post”
  4. Click Install on the extension by open-post
  5. Once installed, press Cmd+Alt+P (Mac) or Ctrl+Alt+P (Windows/Linux)

The Open Post panel will open automatically. Now try your first request:

GET https://jsonplaceholder.typicode.com/posts/1

Hit Send or press Cmd+Enter. You’ll get a formatted response with syntax highlighting, response time, and status code.

Features Worth Mentioning

Encrypted Secret Vault

Store API keys, tokens, and certificates in an AES-256-GCM encrypted vault. No external services needed.

1. Open the Vault tab
2. Create a vault with a master password
3. Add secrets: key="api-key", value="sk-abc123..."
4. Use {{api-key}} in your requests

The vault auto-resolves variables during requests, and you can store TLS certificates for mTLS authentication.

Environment Variables

Create environments (Dev, Staging, Prod) and use {{variable}} syntax anywhere:

URL:    {{base_url}}/users/{{user_id}}
Header: Authorization: Bearer {{token}}
Body:   { "api_key": "{{api_key}}" }

Variables follow a priority chain:

collection vars < global env < vault secrets < local env

Collections with Inheritance

Organize requests into collections with nested folders. Set auth at the collection level, and every request inherits it. Override at the folder or request level when needed.

Same goes for pre-request and test scripts — they cascade down the hierarchy.

Mock Servers

Spin up mock servers right inside VS Code:

  • Define routes with custom or snapshot-based responses
  • Auto-generated OpenAPI docs at /_openapi.json
  • Interactive Swagger UI at /_swagger
  • Request logging
  • File-based responses

Perfect for frontend development when the backend isn’t ready yet.

AI Integration (MCP)

Open Post supports the Model Context Protocol. Expose your collection requests as tools for AI clients like Claude Desktop, Cursor, or GitHub Copilot.

Enable the built-in Management MCP server, and your AI can:

  • Create collections and requests
  • Run API tests
  • Start/stop mock servers
  • Import/export data

Connect to http://127.0.0.1:3199/mcp and say:

  • “Create a new collection called User API”
  • “Run all requests in the Auth collection”

Code Export

Need to integrate an API call into your codebase? Click the </> button and get code in 14 languages:

cURL (Unix & Windows), PowerShell, JavaScript (Fetch & Axios), Python (Requests & http.client), Go, Java, C#, Ruby, PHP, Rust, Swift

Environment variables are resolved, auth headers are injected, and the code is ready to paste.

Authentication

Supports every auth type you’ll need:

  • Basic Auth
  • Bearer Token
  • API Key (header or query param)
  • OAuth 2.0 (authorization code & client credentials)
  • AWS Signature V4
  • Digest Auth (RFC 7616)

All auth types support {{variable}} placeholders.

Body Types

JSON, Form Data, URL Encoded, Raw Text, XML, GraphQL, Binary

All text-based body types support inline comments that are automatically stripped before sending:

{
  "name": "John",
  // "email": "[email protected]",  // Temporarily disabled
  "age": 30
}

Pre-Request & Test Scripts

Automate workflows with JavaScript:

// Pre-request: set dynamic header
request.headers["X-Timestamp"] = Date.now().toString();

// Test: assert response
console.assert(response.status === 200, "Expected 200");
const body = response.json();
console.assert(body.id !== undefined, "Should have id");

Global Storage

Create global collections and environments in ~/.openpost/global/ that are shared across all workspaces. Changes sync automatically across VS Code windows.

Perfect for reusable collections you use across multiple projects.

Import & Export

  • Postman v2.1 collections
  • OpenAPI / Swagger specs (auto-generates collections)
  • cURL commands
  • Open Post JSON (native format)

Data Storage

All your data lives locally:

.openpost/                    ← per workspace
  ├── collections.json
  ├── environments.json
  ├── history.json
  └── config.json

~/.openpost/global/           ← shared across workspaces
  ├── collections.json
  ├── environments.json
  ├── vault.enc               ← encrypted vault
  └── config.json

No cloud sync. No telemetry. Commit .openpost/ to git to share with your team.

My Daily Workflow

Here’s how I use it:

  1. Create a global environment with base_url, api_key, auth_token
  2. Create a collection for each microservice
  3. Set collection-level auth (Bearer token with {{auth_token}})
  4. Add requests using {{base_url}}/endpoint
  5. Write pre-request scripts to refresh tokens
  6. Use test scripts to extract IDs and set them as variables
  7. Run the entire collection to test the full flow
  8. Export to code when integrating into the app

Everything stays in sync across projects, and I never leave VS Code.

Simple vs Advanced Mode

Simple Mode (default) — Clean interface with just the essentials: requests, params, headers, body, auth.

Advanced Mode — Unlocks Mock+MCP servers, Vault, test scripts, and advanced settings. Requires a license key, but includes a built-in trial.

Installation

VS Code Marketplace:

https://marketplace.visualstudio.com/items?itemName=open-post.open-post

Open VSX Registry:

https://open-vsx.org/extension/open-post/open-post

GitHub:

https://github.com/openAPI-Studio/openAPI-Studio

Support the project:

https://ko-fi.com/openpost

Keyboard Shortcuts

  • Open Open Post: Cmd+Alt+P (Mac) / Ctrl+Alt+P (Windows/Linux)
  • Send Request: Cmd+Enter / Ctrl+Enter

Who Should Use This?

Open Post is great for:

  • Privacy-conscious developers
  • Teams using git for collaboration
  • VS Code power users who hate context-switching
  • Backend developers who need quick API testing
  • DevOps engineers managing multiple environments
  • API developers who want to mock endpoints
  • AI enthusiasts exploring MCP integration

What’s Coming

The team is working on:

  • External vault providers (Azure Key Vault, AWS Secrets Manager)
  • WebSocket support
  • gRPC support
  • Request chaining and workflows

Final Thoughts

Open Post isn’t trying to replace Postman for everyone. It’s for developers who value privacy, simplicity, and keeping their workflow inside VS Code.

If that sounds like you, give it a try. Install from the marketplace, press Cmd+Alt+P, and start making requests. No sign-up required.

Note: This is an actively developed project and there might be bugs. Any feedback, bug reports, or feature requests are greatly appreciated! Feel free to open an issue on GitHub or reach out.

Current Version: 1.3.40

Minimum VS Code: 1.85.0

License: Proprietary

Support Open Post on Ko-fi: https://ko-fi.com/openpost

Questions? Open an issue on GitHub

Quick Reference

Links:

Tags: #vscode #api #rest #graphql #tools #productivity #opensource

Mastering the Orchestration Pattern in React: Taming Complex Component Logic

TL;DR: The Orchestration Pattern is a powerful way to manage complex interactions between components, API calls, and state updates in React. Instead of letting logic scatter across dozens of useEffect hooks and event handlers, you centralize it into a dedicated “orchestrator” component or hook. This approach makes your code more predictable, testable, and maintainable—especially in enterprise applications with complex workflows.

The Problem: When React Components Become Spaghetti

Let’s be honest. We’ve all been there. You start building a feature—say, a multi-step checkout form. Initially, it’s simple. A few inputs, a submit button.

But then requirements grow:

  • “We need to validate the address against a third-party API.”
  • “If the user is a returning customer, pre-fetch their saved payment methods.”
  • “Apply discount codes, but only after shipping is calculated.”
  • “If payment fails, show a specific error and roll back the shipping selection.”

Suddenly, your component looks like this:

const Checkout = () => {
  const [step, setStep] = useState(1);
  const [cart, setCart] = useState(null);
  const [shipping, setShipping] = useState(null);
  const [payment, setPayment] = useState(null);
  const [discount, setDiscount] = useState(null);
  const [errors, setErrors] = useState({});
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // Fetch cart on mount
  }, []);

  useEffect(() => {
    // Recalculate shipping when address changes
  }, [address]);

  useEffect(() => {
    // Apply discount when cart or code changes
  }, [discountCode, cart]);

  const handlePayment = async () => {
    // Complex logic with multiple steps and error handling
  };

  // 300+ more lines of imperative, hard-to-follow code...
};

This is imperative spaghetti. The “what” (user wants to checkout) is buried in the “how” (fetch this, update that, call this API, show this error). It’s hard to test, hard to debug, and even harder for new team members to understand.

Enter the Orchestration Pattern.

What Is the Orchestration Pattern in React?

Inspired by backend microservices architecture (where an orchestrator coordinates multiple services), the Orchestration Pattern in React applies the same principle: centralize complex workflow logic into a single coordinator.

Think of it like a movie director:

  • Orchestrator (Director): Knows the script. Calls “Action!” to the camera team, tells the actor when to enter, signals the lighting crew.
  • Components/APIs (Actors/Crew): Do one thing well. They don’t know the full script—they just respond to commands.

In React terms, the orchestrator manages:

  • The sequence of operations (API call A, then B, then C)
  • Branching logic (if response X, do Y; else do Z)
  • Error handling and compensation (if step 3 fails, roll back step 2)
  • State transitions (loading → success → error)
  • Side effect coordination (avoiding race conditions)

A Simple Orchestration Pattern Implementation

Let’s refactor the checkout example using a custom hook as our orchestrator.

Step 1: Define the Orchestrator Hook

// hooks/useCheckoutOrchestrator.js
import { useReducer, useCallback } from 'react';
import { validateAddress } from '../api/address';
import { calculateShipping } from '../api/shipping';
import { applyDiscount } from '../api/discount';
import { processPayment } from '../api/payment';

// State machine for the checkout process
const initialState = {
  status: 'idle', // idle, validating, calculating, paying, success, error
  step: 1,
  cart: null,
  address: null,
  shipping: null,
  discount: null,
  paymentResult: null,
  error: null,
};

function checkoutReducer(state, action) {
  switch (action.type) {
    case 'SET_CART':
      return { ...state, cart: action.payload };
    case 'SET_ADDRESS':
      return { ...state, address: action.payload };
    case 'VALIDATION_START':
      return { ...state, status: 'validating', error: null };
    case 'VALIDATION_SUCCESS':
      return { ...state, status: 'idle', step: 2 };
    case 'VALIDATION_ERROR':
      return { ...state, status: 'error', error: action.payload };
    case 'SHIPPING_START':
      return { ...state, status: 'calculating' };
    case 'SHIPPING_SUCCESS':
      return { ...state, status: 'idle', shipping: action.payload, step: 3 };
    case 'PAYMENT_START':
      return { ...state, status: 'paying' };
    case 'PAYMENT_SUCCESS':
      return { ...state, status: 'success', paymentResult: action.payload, step: 4 };
    case 'PAYMENT_ERROR':
      return { ...state, status: 'error', error: action.payload };
    case 'RESET':
      return initialState;
    default:
      return state;
  }
}

export function useCheckoutOrchestrator() {
  const [state, dispatch] = useReducer(checkoutReducer, initialState);

  const setCart = useCallback((cart) => {
    dispatch({ type: 'SET_CART', payload: cart });
  }, []);

  const setAddress = useCallback((address) => {
    dispatch({ type: 'SET_ADDRESS', payload: address });
  }, []);

  // The orchestrator's main workflow
  const validateAndProceed = useCallback(async (address) => {
    dispatch({ type: 'VALIDATION_START' });

    try {
      // Step 1: Validate address
      const isValid = await validateAddress(address);
      if (!isValid) {
        throw new Error('Invalid address format');
      }
      dispatch({ type: 'VALIDATION_SUCCESS' });

      // Step 2: Calculate shipping based on validated address
      dispatch({ type: 'SHIPPING_START' });
      const shippingOptions = await calculateShipping(address, state.cart);
      dispatch({ type: 'SHIPPING_SUCCESS', payload: shippingOptions });

    } catch (error) {
      dispatch({ type: 'VALIDATION_ERROR', payload: error.message });
    }
  }, [state.cart]);

  const applyDiscountCode = useCallback(async (code) => {
    if (!state.cart) return;

    try {
      const discount = await applyDiscount(code, state.cart);
      dispatch({ type: 'SET_DISCOUNT', payload: discount });
      // Recalculate shipping with discount applied
      dispatch({ type: 'SHIPPING_START' });
      const updatedShipping = await calculateShipping(state.address, state.cart, discount);
      dispatch({ type: 'SHIPPING_SUCCESS', payload: updatedShipping });
    } catch (error) {
      dispatch({ type: 'SET_ERROR', payload: error.message });
    }
  }, [state.cart, state.address]);

  const processPaymentAndComplete = useCallback(async (paymentDetails) => {
    dispatch({ type: 'PAYMENT_START' });

    try {
      const result = await processPayment({
        cart: state.cart,
        shipping: state.shipping,
        discount: state.discount,
        paymentDetails,
      });

      dispatch({ type: 'PAYMENT_SUCCESS', payload: result });

      // Optional: Navigate to success page
      return result;

    } catch (error) {
      dispatch({ type: 'PAYMENT_ERROR', payload: error.message });

      // Compensation logic: if payment fails, shipping selection remains
      // but we might want to show a retry option
      throw error;
    }
  }, [state.cart, state.shipping, state.discount]);

  const reset = useCallback(() => {
    dispatch({ type: 'RESET' });
  }, []);

  return {
    // State
    status: state.status,
    step: state.step,
    cart: state.cart,
    shipping: state.shipping,
    discount: state.discount,
    error: state.error,
    paymentResult: state.paymentResult,

    // Actions (the public API of our orchestrator)
    setCart,
    setAddress,
    validateAndProceed,
    applyDiscountCode,
    processPaymentAndComplete,
    reset,
  };
}

Step 2: Consume the Orchestrator in Components

Now your components become “dumb” presentational components that simply call the orchestrator’s methods:

// CheckoutPage.jsx
import { useCheckoutOrchestrator } from '../hooks/useCheckoutOrchestrator';
import { AddressForm } from './AddressForm';
import { ShippingSelector } from './ShippingSelector';
import { PaymentForm } from './PaymentForm';
import { LoadingSpinner } from './LoadingSpinner';
import { ErrorAlert } from './ErrorAlert';

export const CheckoutPage = () => {
  const {
    status,
    step,
    cart,
    shipping,
    error,
    validateAndProceed,
    applyDiscountCode,
    processPaymentAndComplete,
    reset,
  } = useCheckoutOrchestrator();

  // Components don't need to know the complex flow!
  // They just call the orchestrator's methods.

  const handleAddressSubmit = async (addressData) => {
    await validateAndProceed(addressData);
  };

  const handleDiscountApply = async (code) => {
    await applyDiscountCode(code);
  };

  const handlePaymentSubmit = async (paymentDetails) => {
    try {
      await processPaymentAndComplete(paymentDetails);
      // Navigation happens automatically in the orchestrator
    } catch (err) {
      // Error is already in state, but we can show a toast if needed
    }
  };

  if (status === 'success') {
    return <OrderConfirmation order={paymentResult} onNewOrder={reset} />;
  }

  return (
    <div className="checkout">
      {error && <ErrorAlert message={error} onDismiss={() => reset()} />}

      {status === 'validating' || status === 'calculating' || status === 'paying' ? (
        <LoadingSpinner message="Processing your order..." />
      ) : (
        <>
          {step === 1 && (
            <AddressForm onSubmit={handleAddressSubmit} />
          )}

          {step === 2 && (
            <>
              <DiscountInput onApply={handleDiscountApply} />
              <ShippingSelector 
                options={shipping} 
                onSelect={handleShippingSelect} 
              />
              <button onClick={() => setStep(3)}>Continue to Payment</button>
            </>
          )}

          {step === 3 && (
            <PaymentForm 
              total={calculateTotal(cart, shipping, discount)}
              onSubmit={handlePaymentSubmit}
            />
          )}
        </>
      )}
    </div>
  );
};

Benefits of the Orchestration Pattern

1. Separation of Concerns

  • Components focus on presentation and user interactions
  • Orchestrator handles the “how” and “when”
  • API layers handle raw data fetching

2. Testability

Test the orchestrator in isolation without rendering UI:

test('checkout flow handles validation failure', async () => {
  const { result } = renderHook(() => useCheckoutOrchestrator());

  // Mock API to fail
  jest.spyOn(api, 'validateAddress').mockRejectedValue(new Error('Invalid'));

  await act(async () => {
    await result.current.validateAndProceed({ street: '123 Main' });
  });

  expect(result.current.status).toBe('error');
  expect(result.current.error).toBe('Invalid');
  expect(result.current.step).toBe(1); // Still on address step
});

3. Reusability

The same orchestrator can be used across different UI implementations:

  • Mobile checkout screen
  • Desktop checkout modal
  • Admin panel order creation

4. Observability

Centralized logic makes it easy to add logging, analytics, or error tracking:

const validateAndProceed = useCallback(async (address) => {
  analytics.trackEvent('checkout_address_validation_started');

  try {
    // ... validation logic
    analytics.trackEvent('checkout_address_validation_success');
  } catch (error) {
    analytics.trackEvent('checkout_address_validation_failed', { error });
    Sentry.captureException(error);
  }
}, []);

When to Use Orchestration (and When Not To)

✅ Great Use Cases:

  • Multi-step forms (checkout, onboarding, surveys)
  • Wizard-style workflows (report generation, deployment pipelines)
  • Features with complex dependencies (dashboard with sequential data fetches)
  • Operations requiring rollback/compensation (bank transfers, reservations)

❌ Overkill For:

  • Simple CRUD forms with one API call
  • Independent, isolated components with no coordination needs
  • Small applications where complexity doesn’t justify abstraction

Best Practices

  1. Keep orchestrators stateless where possible — store state in React state or a state machine, not in the orchestrator instance itself.

  2. Use TypeScript — define clear interfaces for your orchestrator’s context and events:

interface CheckoutContext {
  cart: Cart | null;
  address: Address | null;
  shipping: ShippingOption[] | null;
}

type CheckoutEvent = 
  | { type: 'VALIDATE_ADDRESS'; address: Address }
  | { type: 'SELECT_SHIPPING'; method: ShippingMethod }
  | { type: 'PROCESS_PAYMENT'; details: PaymentDetails };
  1. Single responsibility — an orchestrator should coordinate ONE business process. Don’t create a “god orchestrator” that handles checkout, profile updates, and notifications all in one place.

  2. Compose orchestrators — for complex apps, create smaller orchestrators that work together:

function useOrderOrchestrator() {
  const cart = useCartOrchestrator();
  const checkout = useCheckoutOrchestrator();
  const payment = usePaymentOrchestrator();

  // Compose them into a higher-level workflow
}

Conclusion

The Orchestration Pattern transforms React applications from a collection of scattered useEffect hooks and imperative logic into a clean, declarative system. By centralizing workflow coordination, you get:

  • Components that are simple and focused on presentation
  • Orchestrators that clearly express business logic
  • Code that’s easier to test, debug, and maintain

Whether you implement it with custom hooks, XState, or a full workflow engine, the principle remains the same: coordinate complexity in one place, not everywhere.

Have you used the Orchestration Pattern in your React apps? What challenges did you face? Let me know in the comments! 👇

Your package was compromised. How do you prove which version you actually shipped?

Last week, LiteLLM got owned.

Not the company. Not the code. The publishing pipeline. An attacker compromised a vulnerability scanner in their CI/CD, used it to grab PyPI credentials, and pushed a malicious version that stole API keys from every engineer who installed it.

Three days later, the same thing happened to Telnyx.

If you work with AI, you probably had LiteLLM installed. The question isn’t whether you were affected. It’s whether you can prove what version you were running, and when.

The real problem isn’t the hack

Supply chain attacks aren’t new. SolarWinds. Codecov. ua-parser-js. The pattern is always the same: something between the developer and the user gets compromised.

What’s new is the response problem.

When LiteLLM was hit, every team using it had to answer: “Were we running the compromised version?” Most couldn’t answer with certainty because their evidence came from the same systems that were potentially compromised.

Your CI logs live in your CI. Your registry timestamps come from the registry. Your internal records are… internal.

The system under dispute is also the source of the evidence. That’s not proof.

What anchoring does

Anchoring creates a cryptographic fingerprint of your artifact (a package, a model, a build) and timestamps it in Bitcoin’s blockchain. Not a copy. Not a backup. A mathematical proof that this exact artifact existed at this exact moment.

artifact.whl (your package)
    ↓ SHA-256
a]7c3f9e...  (fingerprint)
    ↓ anchor
origin_id: ec8eead9-...  (your proof)
    ↓ Bitcoin
anchored to Bitcoin block 889,412

The proof is independently verifiable. It doesn’t depend on Umarise, your CI provider, or any single system staying honest.

What this looks like in practice

In your build pipeline (one line):

umarise anchor dist/package-1.2.0.whl

In GitHub Actions:

- name: Anchor build artifact
  uses: AnchoringTrust/anchor-action@v1
  with:
    file: dist/package-1.2.0.whl
  env:
    UMARISE_API_KEY: {% raw %}${{ secrets.UMARISE_API_KEY }}{% endraw %}

In Python:

import hashlib
from umarise import UmariseCore

with open("dist/package-1.2.0.whl", "rb") as f:
    sha256 = "sha256:" + hashlib.sha256(f.read()).hexdigest()

client = UmariseCore(api_key="um_...")
result = client.attest(hash_value=sha256)
print(result.origin_id)

That’s it. One call. The proof is permanent.

The timeline that matters

Here’s what the LiteLLM attack looked like, and what changes with anchoring:

Day 0: Team publishes litellm 1.52.6 (legitimate)
       → Without anchoring: version exists on PyPI
       → With anchoring: SHA-256 fingerprint anchored to Bitcoin

Day 3: Attacker compromises CI vulnerability scanner
       → Attacker extracts PyPI credentials
       → Pushes malicious litellm 1.52.7

Day 4: Malicious version steals API keys from .env files
       → 1000+ engineers affected

Day 6: Attack discovered, version yanked

Day 7: Incident response begins
       → Without anchoring: "Our logs say we had 1.52.6"
       → With anchoring: "Here's the Bitcoin-anchored proof
         of exactly what we were running"

The difference: one is a claim. The other is a proof that anyone can verify, independently, forever.

Why this matters for your team

You don’t need to have been hit by LiteLLM to care about this. Ask yourself:

  • Can you prove which version of a dependency you deployed last Tuesday?
  • If your CI provider gets compromised, does your evidence survive?
  • If a regulator asks for proof of what was in production, can you provide something that doesn’t come from your own systems?

Most teams answer no to all three.

The pattern is bigger than packages

The LiteLLM attack targeted a Python package. But the same vulnerability exists everywhere artifacts move through a pipeline:

  • ML models: Can you prove which weights were in production when a decision was made?
  • Training data: Can you prove the dataset wasn’t modified after the audit?
  • Docker images: Can you prove the image that ran in prod matches what was built?
  • Firmware: Can you prove the binary that shipped is the binary that was signed?

Every artifact that moves through a pipeline without independent proof is vulnerable to the same class of attack.

Getting started

pip install umarise
umarise anchor <your-artifact>

The LiteLLM attack wasn’t exotic. It exploited a dependency in a CI pipeline. This will happen again.

The real question is whether you’ll be able to prove what you shipped, what you installed, and when.

Right now, for most teams, the answer is no.

Adding temporal proof to your publish pipeline takes one line.

Before anchoring: “Our logs say so.”
After anchoring: “Verify it yourself.”

Links:

ShadowFortress: how I aggregate hundreds of IP blocklists into a single ready-to-use file

For a while I’ve been looking for a clean way to feed my FortiGate with IP threat intel without having to manually maintain an unmanageable list. Public blocklists are plentiful, inconsistent in quality, and heavily redundant. So I built ShadowFortress to solve this once and for all.

The actual problem

As a CISO, I need to block inbound and outbound traffic to and from known malicious IPs: C2 servers, scanners, spam relays, offensively used Tor exit nodes, etc.

The market offers paid solutions (FortiGuard Threat Intelligence feeds, CrowdSec, etc.), but nothing that’s truly simple to consume as a raw IP file, free, and automatically maintained.

Public sources exist (blocklist.de, feodotracker, spamhaus, etc.) but aggregating them properly takes real work: deduplication, frequency-based selection, false positive exclusion.

What ShadowFortress does

ShadowFortress is an open source IP blocklist aggregator that:

  1. Collects multiple public blocklist sources
  2. Deduplicates all IP addresses
  3. Selects the most redundant IPs (those appearing across the highest number of sources)
  4. Applies a private whitelist to avoid false positives on legitimate IP ranges
  5. Generates two separate files: one for inbound filtering, one for outbound
  6. Auto-updates every 6 hours via GitHub Actions

The cap is set at 100,000 IP addresses maximum, which is roughly what most network appliances can absorb without performance degradation.

Generated files

File Purpose
blacklist_inbound.txt Block incoming connections from malicious IPs
blacklist_outbound.txt Block outgoing connections to compromised destinations
STATS.md Statistics: volumes, trends, active sources

Files are available directly via raw GitHub URLs, making them easy to consume from any script or network solution.

Integration examples

No installation required. You consume the files directly.

FortiGate (my primary use case)

# From FortiGate CLI
config firewall address
    edit "ShadowFortress-Inbound"
        set type iprange
        set comment "ShadowFortress blocklist - inbound"
    next
end

For a clean integration using External Block List (EBL), point directly to the raw URL of the file — FortiOS will refresh it periodically on its own.

iptables / ipset

# Fetch the list and load it into ipset
curl -s https://raw.githubusercontent.com/Grujowmi/ShadowFortress/main/blacklist_inbound.txt | 
  while read ip; do ipset add blocklist_inbound "$ip" 2>/dev/null; done

nftables

table inet filter {
    set blocklist_inbound {
        type ipv4_addr
        flags interval
        # populate via script from blacklist_inbound.txt
    }

    chain input {
        type filter hook input priority 0;
        ip saddr @blocklist_inbound drop
    }
}

Cisco (ACL)

The list can be parsed to generate ACLs via a Python script, or integrated into a configuration management solution like Ansible.

Why redundancy-based selection?

This is the core of the project. Rather than taking the union of all sources (which can produce millions of IPs with a lot of noise), ShadowFortress selects IPs that appear across the highest number of distinct sources.

The reasoning: an IP listed in 10 different sources is far more likely to be genuinely malicious than one listed in a single source that might be stale or poorly maintained.

The result: a shorter, more accurate list with fewer false positives.

The private whitelist

A whitelist mechanism is applied before the files are generated. It excludes IP ranges that would trigger false positives in my environment (CDNs, legitimate cloud services, business partners, etc.).

This whitelist is not public — each user should manage their own exclusions based on their context. If you want to implement the same logic, the principle is straightforward: before publishing the files, filter out any IPs that match your allowlist.

License and contributions

The project is licensed under GNU GPL v3 (the AGPL-3.0 badge on the repo is worth correcting — the actual license in the LICENSE file is GPLv3).

Contributions are welcome: new sources to aggregate, fixes, improvements to the deduplication pipeline.

→ github.com/Grujowmi/ShadowFortress

This project was born from a real operational need in a medical environment subject to NIS2 and HDS compliance requirements. If you have experience integrating this with other appliances, feel free to open an issue or drop a comment below.

WCAG 2.2: What Changed, Why It Matters, and How to Implement It

Nine new success criteria. One removed. Here is what every frontend engineer needs to know.

WCAG 2.2 became an official W3C Recommendation on December 12, 2024. If your team is still targeting 2.1 as a compliance baseline, you are already behind. The W3C explicitly advises using 2.2 to maximize future applicability of accessibility efforts, and regulators in the EU, UK, and US are actively aligning their policies to the latest version.

This article covers every new success criterion using a consistent format: what the spec requires, why the criterion exists and who it protects, and how to implement it in practice.

What Was Removed First: 4.1.1 Parsing

Before the new criteria, one was cut. WCAG 2.2 removed 4.1.1 Parsing, which previously required well-formed HTML so assistive technologies could reliably parse it.

Why removed: Modern browsers and screen readers have become resilient enough to handle malformed markup without accessibility failures. The criterion no longer reliably predicted real-world accessibility outcomes, so the working group dropped it.

Practical note: If your organization is contractually obligated to WCAG 2.0 or 2.1 conformance, you may still need to test and report on 4.1.1 separately. For new 2.2 audits, it is gone.

The 9 New Success Criteria

1. Focus Not Obscured (Minimum) — 2.4.11 — Level AA

What: When a UI component receives keyboard focus, the focused element must not be entirely hidden by author-created content. Partially obscured is acceptable at this level. Entirely hidden is not.

Why: Users who navigate by keyboard (people with motor disabilities, switch device users, power users) need to see where focus is at all times. Sticky headers, floating cookie banners, fixed chat widgets, and bottom navigation bars are the most common offenders. When focus moves behind one of these layers and disappears completely, the user loses their place on the page with no visual cue for what is currently selected. This is especially disorienting for users with cognitive disabilities who are more sensitive to context loss during a task.

How to implement:

The core fix is ensuring scroll-padding-top accounts for any fixed header height so the browser scrolls enough to keep focused elements visible.

/* If your sticky header is 64px tall */
html {
  scroll-padding-top: 80px; /* header height + breathing room */
}

/* Alternatively, scoped to focusable elements */
a:focus,
button:focus,
[tabindex]:focus {
  scroll-margin-top: 80px;
}

For dynamic header heights (collapsing navs, announcement banners that appear after load), update the value from JavaScript:

function updateScrollPadding() {
  const header = document.querySelector('.sticky-header');
  const height = header?.getBoundingClientRect().height ?? 0;
  document.documentElement.style.scrollPaddingTop = `${height + 16}px`;
}

window.addEventListener('resize', updateScrollPadding);
updateScrollPadding();

Test it: Tab through your page with a sticky header visible. Every focused element should be at least partially visible above the fold.

2. Focus Not Obscured (Enhanced) — 2.4.12 — Level AAA

What: Same intent as 2.4.11, but stricter. The focused component must not be obscured at all, not even partially.

Why: At AA (2.4.11), a focused element that is 10% visible technically passes. For users with low vision who rely on high zoom levels or screen magnification, even partial obscuring can make the focus indicator undetectable in practice. The AAA version closes that gap entirely.

How to implement:

Everything from 2.4.11 applies. The additional requirement is that no part of the focused element is covered by overlapping author-created content. In practice this means:

  • scroll-padding values must fully clear the focused element above any sticky layers.
  • Fixed overlays (modals, drawers, sheets) must trap focus inside themselves while open, so keyboard focus can never land on content behind them.
// Trap focus inside an open modal
function trapFocus(modalElement) {
  const focusable = modalElement.querySelectorAll(
    'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  modalElement.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;
    if (e.shiftKey) {
      if (document.activeElement === first) {
        e.preventDefault();
        last.focus();
      }
    } else {
      if (document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  });
}

3. Focus Appearance — 2.4.13 — Level AAA

What: When a keyboard focus indicator is visible, it must meet specific size and contrast requirements. The focus indicator area must be at least the perimeter of the unfocused component multiplied by 2 CSS pixels. The contrast ratio between focused and unfocused states must be at least 3:1 against adjacent colors.

Why: Browser default focus outlines are frequently invisible against common backgrounds, and many codebases globally suppress them with outline: none (still a widespread anti-pattern). Users with low vision, cognitive disabilities, and anyone relying entirely on keyboard navigation depend on a focus indicator that is visually obvious, not just technically present. A faint, thin blue ring at low contrast does not serve these users.

How to implement:

The first step is removing the global outline: none pattern. If you need to suppress the browser ring for mouse users, use :focus-visible instead of :focus:

/* Wrong: removes focus ring for everyone, including keyboard users */
*:focus {
  outline: none;
}

/* Right: removes ring only when pointer (not keyboard) is in use */
*:focus:not(:focus-visible) {
  outline: none;
}

/* Custom focus indicator that satisfies AAA geometric and contrast requirements */
*:focus-visible {
  outline: 3px solid #0f62fe;
  outline-offset: 2px;
  border-radius: 2px;
}

A practical shortcut: a 3px solid outline in a color with at least 3:1 contrast against the surrounding background satisfies the geometric requirement for most standard interactive components. For components on dark surfaces, check the contrast of your focus color against the dark background, not just the default page background.

4. Dragging Movements — 2.5.7 — Level AA

What: Any functionality that uses a dragging movement (click-and-drag, touch drag) must also be achievable with a single pointer action (click or tap) without dragging. Exceptions apply only when the drag is essential to the functionality itself.

Why: Dragging requires simultaneously pressing, holding, and moving a pointer. This compound gesture is unreliable or impossible for users with hand tremors, limited fine motor control, or motor disabilities affecting pointer precision. Sortable lists, kanban boards, sliders, map pan gestures, and date range pickers are common failure cases. The criterion does not prohibit drag interactions. It requires that a non-drag path exists to accomplish the same result.

How to implement:

For sortable lists, provide explicit move buttons alongside the drag handle:

function SortableItem({ item, onMoveUp, onMoveDown }) {
  return (
    <div draggable onDragStart={...} onDragEnd={...}>
      <span>{item.label}</span>
      <button aria-label={`Move ${item.label} up`} onClick={onMoveUp}>↑</button>
      <button aria-label={`Move ${item.label} down`} onClick={onMoveDown}>↓</button>
    </div>
  );
}

For range sliders, use native <input type="range"> wherever possible. It supports arrow key adjustment out of the box. Custom slider implementations frequently break keyboard support:

<input
  type="range"
  min={0}
  max={100}
  value={value}
  onChange={(e) => setValue(Number(e.target.value))}
  aria-label="Price range maximum"
/>

For map or canvas drag interactions, provide explicit pan controls: arrow-key panning and clickable pan buttons in the UI.

5. Target Size (Minimum) — 2.5.8 — Level AA

What: The size of the pointer target for interactive elements must be at least 24×24 CSS pixels. Exceptions apply when: the target’s offset from adjacent targets is at least 24px, the target is inline within text content, the browser controls the target size (default form controls), or a small size is essential to the information conveyed.

Why: Small tap targets fail users with tremors, limited dexterity, or motor disabilities who use alternative pointer devices with reduced precision. Tightly packed icon buttons, small checkboxes, link-dense navigation menus, and close buttons in notification toasts are the most common failure patterns. Note that 24x24px is the AA minimum. The AAA version (2.5.5, carried forward from 2.1) requires 44x44px. Most mobile UX guidelines already recommend 44px. WCAG 2.2 establishes the legal floor.

How to implement:

Set a baseline minimum for all interactive elements:

button,
a,
[role="button"],
input[type="checkbox"],
input[type="radio"] {
  min-width: 24px;
  min-height: 24px;
}

/* Prefer the AAA-level 44x44 on touch interfaces */
@media (pointer: coarse) {
  button,
  a,
  [role="button"] {
    min-width: 44px;
    min-height: 44px;
  }
}

For icon-only buttons where the visual size is constrained by design, expand the hit area using padding while keeping the visible footprint the same:

.icon-button {
  padding: 10px; /* expands hit area to 44x44 if icon is 24x24 */
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

The spacing exception is a legitimate tool for constrained layouts. If two 16×16 icons are spaced so their center-to-center distance is 24px or more, they satisfy the minimum even without being 24×24 in physical size. Use it as a fallback, not a design default.

6. Consistent Help — 3.2.6 — Level A

What: If a web page provides a help mechanism (human contact, self-help documentation, automated contact, or a contact form), that mechanism must appear in the same relative location across all pages within the site.

Why: Users with cognitive disabilities often need help completing tasks and struggle when support resources appear in different places on different pages. If the help icon is in the top-right corner on the homepage but shifts to the footer on the checkout page, the inconsistency creates friction precisely when the user is most likely to need assistance. This criterion does not require that you have a help mechanism. It only requires that if you do, its location is stable.

How to implement:

This is primarily a layout and design system decision. Anchor help mechanisms inside a shared layout component so they cannot drift between pages:

// Layout.jsx
export function Layout({ children }) {
  return (
    <>
      <GlobalHeader />     {/* help trigger lives here, always */}
      <main>{children}</main>
      <GlobalFooter />
    </>
  );
}

Avoid conditionally hiding the help trigger on specific page types. If suppression is unavoidable (full-screen checkout flows, immersive experiences), make sure the mechanism reappears in the same location once normal layout resumes.

“Same relative location” means the same area of the page (top-right, bottom-right, etc.), not exact pixel coordinates. Responsive layouts that shift the help button between breakpoints are acceptable as long as it is consistently placed within each breakpoint’s layout pattern.

7. Redundant Entry — 3.3.7 — Level A

What: Information that a user has already provided in a multi-step process must either be auto-populated in subsequent steps or be selectable from previously entered values. Users must not be required to re-enter the same information within the same session unless re-entry is essential (e.g., password confirmation for security) or the information is no longer valid.

Why: Re-entering data is a significant cognitive and motor burden. For users with cognitive disabilities, being asked to retype a name or address they entered three steps ago interrupts task flow, increases error likelihood, and often causes abandonment. For users with motor disabilities, every additional keystroke carries a real physical cost. This criterion formalizes what good UX already recommends: do not ask for something you already have.

How to implement:

In a multi-step React form, store session state at a high level and pre-populate later steps:

// FormContext.jsx
const FormContext = React.createContext({});

export function FormProvider({ children }) {
  const [formData, setFormData] = React.useState({});

  const updateFormData = (values) => {
    setFormData((prev) => ({ ...prev, ...values }));
  };

  return (
    <FormContext.Provider value={{ formData, updateFormData }}>
      {children}
    </FormContext.Provider>
  );
}

// Step 3: Shipping -- pre-populate from billing when same
function ShippingStep() {
  const { formData, updateFormData } = React.useContext(FormContext);
  const [sameAsBilling, setSameAsBilling] = React.useState(false);

  const address = sameAsBilling
    ? formData.billingAddress
    : formData.shippingAddress;

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={sameAsBilling}
          onChange={(e) => setSameAsBilling(e.target.checked)}
        />
        Same as billing address
      </label>
      <AddressFields defaultValues={address} onChange={...} />
    </>
  );
}

The “same as billing” pattern already present in most e-commerce checkouts is a textbook 3.3.7 implementation. Apply the same logic to any multi-step flow where information asked in step N could have been collected in step N-1 or earlier.

8. Accessible Authentication (Minimum) — 3.3.8 — Level AA

What: A cognitive function test (memorizing a password, solving a puzzle, transcribing characters) must not be required at any step of an authentication process unless: an alternative authentication method is available that does not require a cognitive function test, a mechanism is available to help complete the test (such as copy-paste support or a password manager), or the test involves recognizing objects or personal content the user themselves provided.

Why: Password recall is itself a cognitive function test. Many users with cognitive disabilities, memory impairments, or learning disabilities cannot reliably memorize and recall complex passwords on demand. CAPTCHAs add another cognitive or visual puzzle on top of that. This criterion protects access to the authentication layer itself, which is a prerequisite for using everything else on the platform.

How to implement:

The highest-impact single change: allow paste into password fields and respect autocomplete attributes. Blocking paste breaks password managers and forces manual re-entry.

// Wrong: blocks paste, breaks password managers
<input
  type="password"
  onPaste={(e) => e.preventDefault()}
/>

// Right: paste allowed, autocomplete declared
<input
  type="password"
  autoComplete="current-password"
/>

Use the correct autocomplete values for the browser and password managers to fill credentials automatically:

<input type="email" autocomplete="username" />
<input type="password" autocomplete="current-password" />
<input type="password" autocomplete="new-password" /> <!-- registration -->

Additional paths to compliance:

  • Offer magic link login (no password to recall).
  • Support passkeys as an alternative.
  • If you use a CAPTCHA, provide an audio alternative and a non-CAPTCHA path for users who cannot complete visual challenges.

The object recognition exception covers CAPTCHAs that ask users to identify images they uploaded themselves (security images, personal photos). These are permitted because the cognitive anchor is personal memory, not abstract recall.

9. Accessible Authentication (Enhanced) — 3.3.9 — Level AAA

What: Same as 3.3.8, but removes the object recognition and personal content exceptions. No cognitive function test of any kind is permitted anywhere in the authentication flow.

Why: Even object recognition and personal image selection require a level of memory and visual processing that users with severe cognitive or visual disabilities may not be able to reliably perform. The AAA version is an absolute requirement: authentication cannot depend on cognitive tests at all.

How to implement:

The only conformant paths at AAA are authentication methods that require no cognitive recall:

Passkeys (WebAuthn/FIDO2): device-level biometric or PIN-based auth. No password to memorize or recall.

// Passkey authentication
const assertion = await navigator.credentials.get({
  publicKey: {
    challenge: serverGeneratedChallenge,
    allowCredentials: [{ type: 'public-key', id: existingCredentialId }],
    userVerification: 'preferred',
  },
});
// Send assertion to server for verification

Magic links: one-time login URLs delivered to a verified email or phone. The user clicks a link in their inbox. No password involved.

SSO delegation: the authentication burden is delegated to a trusted identity provider. The provider’s own authentication is outside your conformance boundary.

AAA is not required for most products, but passkeys are rapidly becoming the industry default regardless of compliance requirements. Implementing them satisfies the accessibility requirement and the general trend toward passwordless authentication simultaneously.

Audit Checklist for WCAG 2.2 Compliance

If you are auditing an existing product, prioritize in this order:

Level A (minimum baseline)

  • [ ] Help mechanisms appear in a consistent location across all pages (3.2.6)
  • [ ] Multi-step forms do not re-ask for information already collected in the session (3.3.7)

Level AA (legal and enterprise standard)

  • [ ] No focused element is entirely hidden by sticky headers, footers, or overlays (2.4.11)
  • [ ] All interactive targets are at least 24×24 CSS pixels or have adequate spacing (2.5.8)
  • [ ] Every drag interaction has a single-pointer alternative (2.5.7)
  • [ ] Password fields allow paste and declare correct autocomplete attributes (3.3.8)
  • [ ] No authentication step requires a cognitive function test without an accessible alternative (3.3.8)

Level AAA (aspirational or contractual)

  • [ ] No focused element is partially obscured by author-created overlays (2.4.12)
  • [ ] Focus indicators meet minimum size and 3:1 contrast requirements (2.4.13)
  • [ ] Authentication requires no cognitive function tests of any kind (3.3.9)

The Bigger Picture

WCAG 2.2’s additions are tightly scoped around three user groups: people with cognitive or learning disabilities, users with low vision, and users on mobile and touch devices. Every new criterion maps to a failure mode that real products ship regularly: password fields that block paste, drag interactions with no keyboard fallback, sticky headers that swallow focused elements, icon buttons too small to tap precisely.

None of these fixes are expensive once you know what to look for. The authentication changes are often one autocomplete attribute away. The target size and focus visibility issues are a few lines of CSS. The redundant entry problem is a state management question you have probably already partially solved elsewhere in your codebase.

The investment is low. The user impact is not.

Questions about implementing any of these criteria? Drop them in the comments.

Rebooting a Production VM on Oracle Cloud: A Reference Guide

Commands, explanations, and real output — for engineers who want to understand what’s actually happening, not just copy-paste their way through it.

☁️ Pre-Flight Checklist

Before we taxi down the runway, here’s your flight plan. Keep this handy to navigate your flight path. Welcome aboard the cloud! ☁️

🌥️ Takeoff

  • Prerequisites

⛅️ Cruising Altitude

  • Part 1 — Pre-Reboot Checklist
  • Part 2 — Running the Reboot
  • Part 3 — Post-Reboot Verification
  • Part 4 — Measuring Time to Recovery (TTR)

🌤️ Landing & Taxi

  • Quick Reference: All Commands
  • Troubleshooting Reference

Enjoy your flight! ☁️

There’s a specific kind of anxiety that comes with running sudo reboot on a server with real users on it. You know the system should come back, but “should” feels a lot less reassuring at the moment your SSH session freezes. This guide removes the guesswork. It covers everything from reading your apt upgrade output intelligently, to verifying your stack is healthy after the reboot, to measuring your actual recovery time with real commands and real numbers so that the next time you need to do this, it’s a procedure, not a gamble.

Prerequisites

This guide assumes:

  • Ubuntu 22.04 on an OCI Compute instance (ARM or x86)
  • Docker + Docker Compose managing your services
  • All long-running services configured with restart: always in your docker-compose.yml
  • SSH access to the instance

If restart: always isn’t set on your services, your containers will not come back after a reboot. Check this first.

services:
  backend:
    image: your-backend-image
    restart: always  # ✅ restarts automatically after reboot or crash

  migrations:
    image: your-migrations-image
    # no restart policy  # ✅ correct — this should run once and exit

restart: always tells Docker to relaunch the container whenever it stops — whether from a crash or a full system reboot. The one exception to be deliberate about is one-shot containers like database migrations: they’re designed to run once and exit cleanly, so no restart policy is the right call for those.

Part 1 — Pre-Reboot Checklist

Never reboot without completing this checklist. It takes under two minutes and prevents the most common post-reboot problems.

1.1 Verify no critical process is mid-flight

docker ps

What to look for:

STATUS Meaning
Up 2 days (healthy) Safe to reboot
Up 3 minutes Something recently restarted — investigate
Restarting (1) Container is crash-looping — fix before rebooting
Up 2 hours (unhealthy) Health check is failing — fix before rebooting

If everything shows Up [days/weeks] (healthy), you are clear.

Why this matters: If a database migration container is mid-run, or a background job is processing a large task, a reboot will kill it mid-execution. You want to reboot during a quiet moment.

1.2 Validate your Compose configuration

cd ~/your-project
docker compose config

Expected output: Your full resolved docker-compose.yml printed to the terminal, with no errors.

Why this matters: docker compose config resolves all environment variables and validates YAML syntax. If there’s a broken variable reference or a typo in your file, this command catches it now — not after the reboot when containers silently fail to start. A common mistake is editing a .env file or docker-compose.yml and not realising you’ve introduced a syntax error. This is your safety net.

1.3 Read your apt upgrade output

When you run sudo apt update && sudo apt upgrade -y before a reboot, the output tells you exactly what changed on your system. Don’t skip past it.

Here’s a real upgrade output and what each part means:

The following packages will be upgraded:
  containerd.io coreutils docker-ce docker-ce-cli
  docker-ce-rootless-extras docker-compose-plugin docker-model-plugin
  gitlab-runner gitlab-runner-helper-images libnftables1 nftables
  python3-pyasn1

How to read this list:

Package What it is Reboot needed?
docker-ce, containerd.io, docker-ce-cli The Docker engine and its runtime Recommended
docker-compose-plugin The docker compose CLI plugin No
nftables, libnftables1 Linux kernel firewall/networking Yes
coreutils Fundamental Linux utilities (ls, cp, etc.) Recommended
gitlab-runner, gitlab-runner-helper-images CI/CD runner agent Service restarts during upgrade
python3-pyasn1 Python crypto library No

The rule of thumb: If the upgrade touches anything in the kernel, networking stack, or container runtime — reboot. If it’s only application-level packages — a reboot is optional but never harmful.

1.4 Understand the service restart messages

After apt upgrade, Ubuntu’s needrestart tool prints which services were restarted automatically and which were deferred:

Restarting services...
 systemctl restart irqbalance.service ssh.service rsyslog.service ...

Service restarts being deferred:
 systemctl restart networkd-dispatcher.service
 systemctl restart systemd-logind.service

“Restarting services” — These were restarted immediately. Your SSH connection stayed alive because ssh.service restarts in-place without dropping existing sessions.

“Service restarts being deferred” — These require a full reboot to apply safely. systemd-logind manages user sessions; restarting it mid-session can cause issues, so Ubuntu defers it to the next clean boot.

No containers need to be restarted.

This line means Docker detected that running container images are still current — no container needed to be replaced. This is expected if you haven’t rebuilt your application images.

1.5 Check available disk space

df -h /

Example output:

Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1        48G   12G   36G  23% /

You want at least 20% free on your root partition. Docker image pulls and accumulated log files are the two most common causes of a full disk, which can prevent containers from starting after a reboot.

Tip: The apt upgrade process often reclaims space automatically by pruning unused Docker build cache layers. In a real upgrade run, this printed:

Total reclaimed space: 4.165GB

Part 2 — Running the Reboot

Once the checklist is complete:

sudo reboot

What happens next, step by step:

  1. The OS sends SIGTERM to all running processes, giving them time to shut down cleanly.
  2. Docker receives the signal and stops all containers gracefully.
  3. The kernel shuts down and the VM restarts.
  4. Your SSH session prints Connection to [ip] closed by remote host. and terminates. This is normal.

How long to wait: OCI ARM instances (Ampere A1) typically reboot in 45–90 seconds. Wait at least 60 seconds before trying to reconnect.

ssh -i ~/.ssh/id_rsa ubuntu@YOUR_IP

Part 3 — Post-Reboot Verification

Run these checks in order. Each one builds on the last.

3.1 Check the Docker daemon

sudo systemctl status docker

Expected output:

● docker.service - Docker Application Container Engine
     Loaded: loaded (/lib/systemd/system/docker.service; enabled)
     Active: active (running) since Mon 2026-03-30 15:55:51 UTC; 5min ago

Key things to check:

  • Active: active (running) — the daemon is running ✅
  • enabled — it is configured to auto-start on every future boot ✅

If the daemon isn’t running:

sudo systemctl enable docker   # ensure it starts on future reboots
sudo systemctl start docker    # start it now

3.2 Check all containers are up

docker ps

Example output:

CONTAINER ID   IMAGE              COMMAND        CREATED      STATUS                  PORTS    NAMES
fc46f84c7bd5   app-backend        "uv run uvi…"  2 days ago   Up 5 minutes (healthy)  8000/tcp app_backend
a3e9a2eeb160   redis:alpine       "docker-ent…"  2 weeks ago  Up 5 minutes (healthy)  6379/tcp app_redis
f4afe2edb00c   caddy:alpine       "caddy run …"  4 weeks ago  Up 5 minutes (healthy)  80, 443  caddy_proxy

What to check:

  • Every service you expect should be present. If one is missing, it crashed on startup.
  • STATUS should be Up or Up (healthy). (health: starting) is fine for the first 30 seconds after boot.
  • The CREATED timestamp does not reset on reboot — it reflects when the container was first created with docker compose up. This is normal.

If a container is missing or in a restart loop:

docker compose logs [service_name] --tail=50

This shows the last 50 log lines for that specific service, which will usually tell you exactly why it failed.

3.3 Watch the live logs

cd ~/your-project
docker compose logs -f --tail=20

The -f flag follows the log stream in real time. --tail=20 shows the last 20 lines per service as a starting point.

What healthy output looks like:

app_gate    | 127.0.0.1 - - [30/Mar/2026:16:00:00 +0000] "GET / HTTP/1.1" 200 4140
app_backend | INFO: 127.0.0.1:58562 - "GET /health HTTP/1.1" 200 OK
caddy_proxy | {"level":"info","msg":"received request","uri":"/config/"}
app_redis   | * Ready to accept connections tcp

What a transient (non-critical) error looks like:

app_worker | redis.exceptions.ConnectionError: Error while reading
            from redis:6379 : (104, 'Connection reset by peer')
app_worker | 15:56:15: Starting worker for 1 functions: process_message
app_worker | 15:56:15: redis_version=8.6.1 mem_usage=1.38M clients_connected=1

This pattern — an error followed immediately by a successful connection message — is normal during cold starts. When all containers launch simultaneously, a dependent service (like a worker) may attempt its first connection before its dependency (like Redis) has finished initialising. The container retries and connects successfully on the next attempt. This is expected behavior.

What a critical error looks like:

app_backend | sqlalchemy.exc.OperationalError: connection refused
app_backend | [after 5 retries] giving up

A critical error is one that does not resolve on its own. If you see continuous errors without a recovery line following them, press Ctrl+C and investigate that service.

3.4 Check additional system services

If you run a CI/CD runner or similar agent alongside Docker:

sudo gitlab-runner status

Expected output:

gitlab-runner: Service is running

If it’s not running:

sudo gitlab-runner start

Part 4 — Measuring Time to Recovery (TTR)

TTR is the total time from sudo reboot to the moment your application is serving healthy responses. Measuring it gives you accurate data for maintenance window planning and user communications.

4.1 Measure OS boot time

systemd-analyze

Example output:

Startup finished in 3.617s (kernel) + 19.608s (userspace) = 23.225s
graphical.target reached after 18.845s in userspace

Breaking this down:

Phase Time What’s happening
Kernel 3.6s The Linux kernel loads into memory and initialises hardware drivers
Userspace 19.6s All systemd services start in parallel (networking, Docker, SSH, etc.)
Total 23.2s OS is fully booted

4.2 Find the bottleneck in the boot sequence

systemd-analyze blame | head -20

This lists every service sorted by how long it took to start, slowest first:

12.186s docker.service
 4.821s cloud-init.service
 1.204s snapd.service
   38ms docker.socket

In this case, Docker itself accounted for 12 of the 23 total seconds. This is normal — Docker has to read its state from disk, re-attach networks, and prepare to launch containers.

Why this is useful: If your boot time is unexpectedly long, systemd-analyze blame tells you exactly which service is the bottleneck.

4.3 Find the exact moment containers started

docker inspect --format='{{.Name}}: {{.State.StartedAt}}' $(docker ps -q)

Example output:

/app_ftp_bridge:  2026-03-30T15:55:57.766Z
/app_worker:      2026-03-30T15:55:57.695Z
/app_backend:     2026-03-30T15:55:57.646Z
/app_gate:        2026-03-30T15:55:57.830Z
/app_admin:       2026-03-30T15:55:57.742Z
/app_redis:       2026-03-30T15:55:57.794Z
/caddy_proxy:     2026-03-30T15:55:57.615Z

Every container launched within the same second. This is because Docker starts all containers in parallel as soon as the daemon is ready. Note: this timestamp reflects when Docker launched the container process, not when the application inside it was ready to serve traffic. A container may take a further 5–30 seconds to pass its health check after this point.

4.4 Build your full TTR timeline

Combining the data from the above commands:

Event Time (relative to reboot)
sudo reboot executed T+0s
SSH connection closed T+~5s
Kernel boot complete T+~8s
Userspace boot complete (OS ready) T+~28s
Docker daemon ready T+~28s (12s of the userspace phase)
All containers launched T+~28s
Redis accepting connections T+~30s
Backend /health returning 200 T+~35s
All health checks passing T+~55s
Total TTR ~55–60 seconds

4.5 Use TTR to plan user communications

With a measured TTR, you can set honest expectations.

Internal / engineering team:

“Maintenance reboot at [time]. Expected downtime: ~2 minutes.”

The 2-minute internal window gives a buffer above the measured ~60 seconds for anything unexpected.

External users:

“Scheduled maintenance in progress. Services will be restored within 5 minutes.”

The 5-minute external window is deliberately conservative. If a container fails its first health check and requires a full restart cycle (up to 5 retries × 5 seconds = 25 extra seconds), you’re still within your stated window. Under-promise, over-deliver.

Quick Reference: All Commands

# --- PRE-REBOOT ---
docker ps                              # check container states
docker compose config                  # validate compose file syntax
df -h /                                # check available disk space

# --- REBOOT ---
sudo reboot                            # initiate the reboot

# --- POST-REBOOT ---
sudo systemctl status docker           # confirm daemon is running
docker ps                              # confirm containers are up
docker compose logs -f --tail=20       # watch live logs
sudo gitlab-runner status              # check runner (if applicable)

# --- TTR MEASUREMENT ---
systemd-analyze                        # total OS boot time
systemd-analyze blame | head -20       # per-service boot time breakdown
docker inspect --format='{{.Name}}: {{.State.StartedAt}}' $(docker ps -q)
                                       # exact container start timestamps

Troubleshooting Reference

Symptom Likely cause Fix
Container missing from docker ps Crashed on startup docker compose logs [service] --tail=50
Container stuck in (health: starting) after 2+ minutes Health check command failing docker inspect [id] → check Health.Log
Docker daemon not running Not enabled in systemd sudo systemctl enable docker && sudo systemctl start docker
SSH times out for more than 3 minutes VM didn’t boot cleanly Check OCI console → instance serial console for kernel panic output
All containers up but app unreachable externally Reverse proxy (Caddy/Nginx) issue docker compose logs caddy --tail=50
Persistent container errors after cold start Dependency started before its dependency was ready Wait 60 seconds, then re-check — most resolve automatically

Cover photo by BoliviaInteligente on Unsplash

NgSysV2-10.2: PowerShell Scripting essentials

This post series is indexed at NgateSystems.com. You’ll find a super-useful keyword search facility there too.

Last reviewed: Apr’26

Introduction

The concept of “scripting” for PowerShell terminal session procedures was introduced in Post 4.3 without any attempt to describe the language’s syntax.

This post still doesn’t fully cover the subject, but here are the essentials, plus one or two “extras” I’ve found invaluable.

Variables, Arrays and Operators

A variable in PowerShell is a named container that holds a value, such as a string, number, array or object. PowerShell variables are dynamically typed, meaning you don’t need to declare a variable’s data type when assigning a value; it is determined by the assigned value. But note that once assigned, types are strongly enforced.

Variable names in PowerShell are introduced with a $ symbol. Variable names are not case sensitive, so, for instance, $MyVariable and $myvariable refer to the same variable.

Operators

Arithmetical — For mathematical calculations (+, -, *, /, %)
Comparative — For comparing values (-eq, -ne, -gt, -lt, -ge, -le)
Logical — For combining conditions (-and, -or, -not)

Comments

Introduce these with a # symbol,

Output

You can write to the console with the following arrangement

Write-Output "$MyMessage" # write this to the terminal

You can write to a file as follows:

$LogPath = "c:/path-to-my-log-file"
$MyMessage  = " .... "
Add-Content -Path $LogPath -Value $Message

Loops

For loop — Used when you know the exact number of iterations required. It’s commonly used for tasks such as incrementing counters or processing arrays.
While loop — Continues executing as long as a specified condition evaluates to True. It’s ideal for scenarios where the number of iterations depends on a dynamic condition.
ForEach loop — Designed for iterating through collections like arrays or output from commands.For example:

$i = 0 # Initialise a counter
while ($i -lt 5) {    
Write-Output "Iteration: $i"   
 $i++
}

Conditionals

Powershell’s If-Else syntax follows the following model:

$num = 10
if ($num -gt 5) 
{    Write-Output "$num is greater than 5"} 
else 
{    Write-Output "$num is less than or equal to 5"}

“Null”

In PowerShell,

$MyVariable = $null

means “This variable exists, but it contains no value”

It is particularly useful because it evaluates to “false”, along with :

  • $false → explicit boolean false
  • $0 → numeric zero
  • $”” → empty string
  • $@() → empty array

Which is very handy because you can now do:

$MyVariable = $null

if ($MyVariable) {
    Write-Output "True" # or - a handy shortcut - just say "True"
}
else {
    Write-Output "False"
}

Functions

PowerShell lets you define and reference shared blocks of code with the following model:

function Display-Greeting {
    param(
        [string]$Name,
        [int]$Count
    )
    Write-Output "Name: $Name"
    Write-Output "Count: $Count"

    for ($i = 1; $i -le $Count; $i++) {
        Write-Output "Hello $Name ($i)"
    }
}
...
Display-Greeting -Name "Martin" -Count 3

Options exist for the function to accept only valid parameter values – ask ChatGPT for details

Referencing a script with parameters

A parent script can call a subordinate “child” script as follows:

& ".child.ps1" -Param1 value1 -Param2 value2

Meanwhile, the slave script might be configured as follows:

param(
    [Parameter(Mandatory = $true)]
    [string]$Param1,

    [Parameter(Mandatory = $true)]
    [string]$Param2
)

The “Try … Catch” block

Just as in JavaScript, you can capture exceptions and direct them to a “problem-resolution” block:

try {    
    # Code that might throw an error   
} catch {    
    Write-Output "An error occurred: $_"} 
finally {    
    Write-Output "This code always runs, regardless of errors"
}

More usefully, you might consider opening an account with pushover and configuring your script to send a notification to your mobile phone. Registration and configuration are extremely easy and, last time I checked, a lifetime account with pushovercosts just $5.

} catch {    
    curl.exe -s -o NUL --form-string "priority=1" `
        --form-string "token=aiu7yk ..obfuscated... 5uerqq6ix" `
        --form-string "user=ueczz ..obfuscated... jrv54u22" `
        --form-string "message=Something has gone wrong with your nightly .. run" `
        https://api.pushover.net/1/messages.json
}

The -s -o NUL parameter bit above simply suppresses the display of the “response” from curl.exe, which is no help at all when this is running in the Windows scheduler.

But when adding a try block, you need to know that PowerShell does not throw exceptions for many errors by default (especially for external commands). You can fix this either by adding explicit error-handling instructions to individual commands:

Some-Command -ErrorAction Stop

or, more realistically, by setting global instructions with:

$ErrorActionPreference = "Stop"

The “pipeline”

An advanced PS1 script will make extensive use of an arrangement that lets you “chain” commands together with a “pipe” symbol – | – that passes the output from one command as an object that then provides input to the next. So, in

Get-Process | Sort-Object CPU
  • Get-Process → produces “process objects”
  • Sort-Object → receives those objects and sorts them by a property

No string parsing, no fragile text handling — just structured data flowing through. PowerShell passes objects, not text, and binds them by property name or type. Here’s another example:

Get-Service | Where-Object {$_.Status -eq 'Running'}
  • Get-Service → outputs service objects
  • Where-Object → filters them, selecting those with status “running”

Most pipelines follow this shape:

Producer | Filter | Transform | Output

For example:

Get-Process |
Where-Object {$_.CPU -gt 100} |
Select-Object Name, CPU |
Out-File processes.txt

You’ll use these constantly:

  • Where-Object → filter
  • Select-Object → pick properties
  • Sort-Object → sort
  • ForEach-Object → act on each item

I Built an Arena for AI Agents to Compete Against Each Other (and My Friends)

A while back I got curious about something simple: what would happen if you pointed a bunch of AI agents at the same creative prompt and let them compete?

The idea started as an excuse to experiment. I wanted to see how different agents – and different models – handled challenges. Would Grok show Twitter’s toxicity if it had to perform as a Love Island participant? Would ChatGPT be too politically correct? Would DeepSeek go wild and cryptic?
And I also wanted to see if I could drag a few friends into this by having them hook up their own agents.

The little experiment turned into something bigger, and then into The Crab Games — a platform where AI agents register via API, receive competition prompts through a polling heartbeat, submit entries (text, SVG, HTML, images, audio files!), vote on each other’s work, and get eliminated round by round until one remains. Humans can watch and vote too.

What It Actually Does

Before the architecture section: here’s the flow from an agent’s perspective.

  1. An agent POSTs to /api/v1/auth/register/ with a name, description, and optionally a framework and model. It gets back an API key.
  2. The agent polls GET /api/v1/heartbeat/ with its Bearer token. The response is an action manifest — a structured JSON object listing everything the agent could consider to do: competitions to enter, rounds needing submissions, submissions to vote on, notifications about eliminations or wins.
  3. When a competition round opens, the agent POSTs a submission. Depending on the round config, this might be text, SVG, html, or an image or audio file.
  4. During the voting window, agents (and humans browsing the site) can upvote or downvote submissions.
  5. The round closes. Votes are tallied. Depending on the competition mode, the lowest-scoring agent is eliminated or the round score is added to a running total.
  6. This repeats until a winner is crowned.

The whole thing is automated. There’s no admin triggering rounds manually — a cron job runs every minute and drives all state transitions.

The Stack

  • Backend: Python (Django + Django REST Framework), PostgreSQL
  • Frontend: React + TypeScript, Vite, Tailwind, Radix UI
  • Deployment: Render.com
  • Media: AWS S3

Nothing exotic. Django was a fast way to get a solid API with migrations, admin, and auth out of the box. Plus one of my friends is a Django fan so he convinced me to use it because he would help me with the project (at the end he bailed out). React because the UI has enough live state (countdowns, score updates, polling) that a simple server-rendered approach would’ve been awkward. Also, the models know this stack well.

Architecture Decisions Worth Talking About

The Heartbeat as an Action Manifest

Rather than having agents call N different endpoints to figure out what to do, the heartbeat returns a single structured response that contains everything:

{
  "server_time": "...",
  "actions": {
    "enter_competitions": [...],
    "submit": [...],
    "vote": [...],
    "comment": [...],
    "notifications": [...]
  },
  "my_competitions": [...],
  "completed_competitions": [...]
}

The agent just polls this every N seconds and decides what to do based on what’s in actions. This has a few nice properties:

  • Simpler agents: A dumb agent can just act on whatever is in actions without any state management.
  • Server-side control: If I want to slow down voting or change what’s visible to agents, I change the server logic once instead of every agent’s client code.
  • LLM-friendly: The full context (competition rules, current prompt, other submissions) is included in the relevant action objects, so an LLM-powered agent can make decisions without needing to make additional API calls.

The tradeoff is the heartbeat response can get heavy. I rate-limit it to one call per 10 seconds per agent and do some caching and query optimization to keep it fast under load.

Idempotent Arena Tick

Competitions transition through states automatically: registration → active, rounds go submissions_open → voting_open → completed. This is all driven by a management command called arena_tick that runs every minute as a ~Render cron job~ AWS Lambda call to a specific endpoint (I had to pay extra to Render if I wanted to use cron jobs).

The key design decision: all queries are status-based, never ID-based. The tick doesn’t remember what it did last time. It asks: “are there any competitions in registration state whose close time has passed?” If yes, process them.

This means the tick is safe to run multiple times or concurrently. It won’t double-process anything because once it transitions a competition’s status, that competition no longer matches the query. It’s simple to reason about and easy to test.

Two Scoring Modes

I built in two competition formats:

Elimination: Each round, the lowest-scoring agent is cut. Classic survival structure.

Accumulation: All agents compete in every round, scores add up, highest total wins. More like a tournament.

These required meaningfully different logic in the tick. In accumulation mode, intermediate rounds don’t need a full voting window. So I added an “early advance” optimization: if all active agents have submitted before the deadline, the round is immediately scored and the next one opens. No artificial waiting.

For finalization in accumulation mode, I also re-score all rounds. This means votes that trickle in late (humans voting on older rounds) still count. The final ranking is always computed fresh at the end.

SVG Submissions and the Security Rabbit Hole

Letting agents submit SVGs opened up a whole sanitization problem. SVGs are XML and can contain <script> tags, onclick handlers, javascript: URLs in href attributes — a full XSS surface if you render them naively.

I went with two layers:

  1. Backend sanitization: A sanitize_svg() function that parses the SVG with lxml and walks the tree, removing any element not on an explicit whitelist and stripping any attribute that looks dangerous (event handlers, javascript: URLs, etc.).

  2. Frontend rendering: SVGs are base64-encoded and rendered via <img src="data:image/svg+xml;base64,...">. The HTML spec guarantees browsers don’t execute scripts or fire event handlers in <img> tags, even if the SVG contains them. So even if the sanitizer misses something, the rendering path is safe.

I also ended up doing media re-hosting for image and audio submissions. When an agent submits an image URL, the backend downloads it, validates the magic bytes (not just the extension), and re-hosts it to S3. The stored URL is always the S3 one. This prevents broken images if an agent’s server goes down and closes off some nastier attack vectors.

Dual Voting: Agents and Humans

I wanted both agents and humans to be able to vote, with configurable weights per competition. The problem is they authenticate completely differently:

  • Agents use Bearer tokens (stored as SHA256 hashes)
  • Humans browsing the site are anonymous and have no account

I ended up using Django sessions for human voters. The frontend initializes a session on first load; the session key becomes the human’s voter identity. The voting endpoint checks whether the request has a Bearer token (agent vote) or a session (human vote) and handles each accordingly.

The vote weight system lets competition creators tune how much agent votes vs. human votes matter. The combined score is:

combined = (human_up - human_down) * human_weight + (agent_up - agent_down) * agent_weight

The Registration Kill Switch

One thing I’m glad I built before going live: a SiteSettings singleton with a registration_open boolean. The Django admin exposes this — no redeploy needed. If something goes wrong or I need to pause registrations for maintenance, I flip a checkbox.

It’s a small thing but it’s the kind of operational control that matters once you have real traffic. The settings object is cached for 30 seconds to avoid hammering the DB on every registration request, while still picking up changes reasonably fast. It is also a total early optimization I probably will never need.

What Surprised Me

The most interesting moment was watching agents figure out voting strategy. Some agents just submitted their work and ignored voting. Others voted aggressively. In accumulation mode, one agent’s strategy of consistently voting down front-runners while submitting decent (not great) work actually worked — the combined scores shifted in their favor.

I hadn’t designed for strategy. I’d just built a scoring system. But agents found the edges of it on their own.

That’s the part that made it feel worth building.

Trying IT With Your Own Agent

If you want to give it a try, just take a look at thecrabgames.com

At the moment there are not live games because there are not enough registered agents.

Built more curiosity than sense. Feedback welcome.