# Whois Hostname Cloaking Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Make soju display `<bouncer-name>.irc.now` in whois instead of the raw connection IP.

**Architecture:** Fork soju with a minimal `hostname-cloak` config directive patch, build a custom container image, update the soju-operator to emit the new directive and use the custom image.

**Tech Stack:** Go (soju patch), Rust (operator/web-api changes), OCP builds

---

### Task 1: Create soju patch file

**Files:**
- Create: `crates/soju-operator/soju/hostname-cloak.patch`

**Step 1: Write the patch**

The patch modifies two files in soju's source:

`config/config.go` -- add `HostnameCloak` field to `BasicServer` and parse it from config:

```go
// In BasicServer struct, add after Hostname:
HostnameCloak string

// In raw config struct inside Load(), add:
HostnameCloak string `scfg:"hostname-cloak"`

// In Load() body, after the hostname parsing block:
if raw.HostnameCloak != "" {
    srv.HostnameCloak = raw.HostnameCloak
}
```

`downstream.go` -- override hostname when cloak is configured:

```go
// In newDownstreamConn(), replace:
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
    dc.hostname = host
} else {
    dc.hostname = remoteAddr
}

// With:
if cloak := dc.srv.Config().HostnameCloak; cloak != "" {
    dc.hostname = cloak
} else if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
    dc.hostname = host
} else {
    dc.hostname = remoteAddr
}
```

Write a unified diff patch file containing both changes.

**Step 2: Verify patch format**

Run: `cat crates/soju-operator/soju/hostname-cloak.patch`
Expected: valid unified diff with `--- a/` and `+++ b/` headers

**Step 3: Commit**

```bash
git add crates/soju-operator/soju/hostname-cloak.patch
git commit -m "feat(soju): add hostname-cloak patch for whois cloaking"
```

---

### Task 2: Create custom soju Containerfile

**Files:**
- Create: `crates/soju-operator/soju/Containerfile`

**Step 1: Write the Containerfile**

```dockerfile
FROM docker.io/golang:1.23 AS build
WORKDIR /src
RUN git clone https://git.sr.ht/~emersion/soju . && git checkout v0.9.0
COPY hostname-cloak.patch .
RUN git apply hostname-cloak.patch
RUN CGO_ENABLED=0 go build -o /soju ./cmd/soju
RUN CGO_ENABLED=0 go build -o /sojuctl ./cmd/sojuctl

FROM registry.access.redhat.com/ubi9-minimal
COPY --from=build /soju /usr/local/bin/
COPY --from=build /sojuctl /usr/local/bin/
USER 1000
ENTRYPOINT ["soju"]
```

Pin to a tagged release (v0.9.0 or latest stable). Check `git.sr.ht/~emersion/soju` for the current release tag before writing.

**Step 2: Commit**

```bash
git add crates/soju-operator/soju/Containerfile
git commit -m "feat(soju): add Containerfile for custom soju image"
```

---

### Task 3: Add OCP BuildConfig for custom soju image

**Files:**
- Create: `crates/soju-operator/soju/buildconfig.yaml`

**Step 1: Write the BuildConfig + ImageStream**

```yaml
apiVersion: build.openshift.io/v1
kind: BuildConfig
metadata:
  name: soju
  namespace: irc-josie-cloud
spec:
  source:
    type: Binary
  strategy:
    type: Docker
    dockerStrategy:
      dockerfilePath: Containerfile
  output:
    to:
      kind: ImageStreamTag
      name: soju:latest
---
apiVersion: image.openshift.io/v1
kind: ImageStream
metadata:
  name: soju
  namespace: irc-josie-cloud
```

**Step 2: Commit**

```bash
git add crates/soju-operator/soju/buildconfig.yaml
git commit -m "feat(soju): add OCP BuildConfig for custom soju image"
```

---

### Task 4: Build and push custom soju image

**Step 1: Apply the BuildConfig**

Run: `oc apply -f crates/soju-operator/soju/buildconfig.yaml`
Expected: buildconfig and imagestream created

**Step 2: Start the build**

Run: `oc start-build soju --from-dir=crates/soju-operator/soju/ -n irc-josie-cloud --follow`
Expected: build completes, image pushed to internal registry

**Step 3: Verify image**

Run: `oc get istag soju:latest -n irc-josie-cloud`
Expected: image stream tag exists with recent timestamp

---

### Task 5: Add hostname_cloak to CRD and update tests

**Files:**
- Modify: `crates/soju-operator/src/types.rs:16-30`
- Modify: `crates/soju-operator/src/testutil.rs`

**Step 1: Write the failing test**

In `crates/soju-operator/src/types.rs`, add to the existing tests:

```rust
#[test]
fn default_spec_has_no_hostname_cloak() {
    let spec = SojuBouncerSpec::default();
    assert!(spec.hostname_cloak.is_none());
}
```

**Step 2: Run test to verify it fails**

Run: `cargo test -p soju-operator default_spec_has_no_hostname_cloak`
Expected: FAIL -- no field `hostname_cloak`

**Step 3: Add the field**

In `SojuBouncerSpec` struct, add after the `hostname` field:

```rust
#[serde(default)]
pub hostname_cloak: Option<String>,
```

**Step 4: Run test to verify it passes**

Run: `cargo test -p soju-operator default_spec_has_no_hostname_cloak`
Expected: PASS

**Step 5: Update test_bouncer helper**

In `crates/soju-operator/src/testutil.rs`, add `hostname_cloak` to `test_bouncer()`:

```rust
hostname_cloak: Some("irc.example.com".to_string()),
```

**Step 6: Run all operator tests**

Run: `cargo test -p soju-operator`
Expected: all pass

**Step 7: Commit**

```bash
git add crates/soju-operator/src/types.rs crates/soju-operator/src/testutil.rs
git commit -m "feat(soju-operator): add hostname_cloak field to CRD spec"
```

---

### Task 6: Emit hostname-cloak in configmap

**Files:**
- Modify: `crates/soju-operator/src/resources/configmap.rs:26-54`

**Step 1: Write the failing test**

In `crates/soju-operator/src/resources/configmap.rs`, add:

```rust
#[test]
fn generates_hostname_cloak_directive() {
    let bouncer = test_bouncer();
    let cm = build_configmap(&bouncer, "host=localhost dbname=test", None);
    let data = cm.data.unwrap();
    let config = &data["config"];
    assert!(config.contains("hostname-cloak irc.example.com"));
}
```

**Step 2: Run test to verify it fails**

Run: `cargo test -p soju-operator generates_hostname_cloak_directive`
Expected: FAIL -- config does not contain `hostname-cloak`

**Step 3: Add hostname-cloak emission**

In `generate_soju_config()`, after the `hostname` line push:

```rust
if let Some(cloak) = &spec.hostname_cloak {
    lines.push(format!("hostname-cloak {cloak}"));
}
```

**Step 4: Run test to verify it passes**

Run: `cargo test -p soju-operator generates_hostname_cloak_directive`
Expected: PASS

**Step 5: Add negative test**

```rust
#[test]
fn omits_hostname_cloak_when_not_set() {
    let mut bouncer = test_bouncer();
    bouncer.spec.hostname_cloak = None;
    let cm = build_configmap(&bouncer, "host=localhost dbname=test", None);
    let data = cm.data.unwrap();
    let config = &data["config"];
    assert!(!config.contains("hostname-cloak"));
}
```

**Step 6: Run all tests**

Run: `cargo test -p soju-operator`
Expected: all pass

**Step 7: Commit**

```bash
git add crates/soju-operator/src/resources/configmap.rs
git commit -m "feat(soju-operator): emit hostname-cloak directive in soju config"
```

---

### Task 7: Update deployment to use custom soju image

**Files:**
- Modify: `crates/soju-operator/src/resources/deployment.rs:15`

**Step 1: Write the failing test**

In `crates/soju-operator/src/resources/deployment.rs`, add:

```rust
#[test]
fn deployment_uses_custom_soju_image() {
    let dep = build_deployment(&test_bouncer(), "testhash");
    let containers = dep.spec.unwrap().template.spec.unwrap().containers;
    let image = containers[0].image.as_ref().unwrap();
    assert!(image.contains("soju"), "image should reference soju");
    assert!(!image.contains("codeberg.org"), "should not use upstream image");
}
```

**Step 2: Run test to verify it fails**

Run: `cargo test -p soju-operator deployment_uses_custom_soju_image`
Expected: FAIL -- image contains "codeberg.org"

**Step 3: Update the image constant**

Change the `SOJU_IMAGE` constant:

```rust
const SOJU_IMAGE: &str = "image-registry.openshift-image-registry.svc:5000/irc-josie-cloud/soju:latest";
```

**Step 4: Run test to verify it passes**

Run: `cargo test -p soju-operator deployment_uses_custom_soju_image`
Expected: PASS

**Step 5: Run all tests**

Run: `cargo test -p soju-operator`
Expected: all pass

**Step 6: Commit**

```bash
git add crates/soju-operator/src/resources/deployment.rs
git commit -m "feat(soju-operator): use custom soju image with hostname cloaking"
```

---

### Task 8: Update web-api to set hostname_cloak on bouncer creation

**Files:**
- Modify: `crates/web-api/src/k8s.rs:24-27`
- Modify: `crates/web-api/src/routes/bouncer.rs:64-67`

**Step 1: Add hostname_cloak to web-api's SojuBouncerSpec**

In `crates/web-api/src/k8s.rs`, add the field:

```rust
pub struct SojuBouncerSpec {
    pub hostname: String,
    pub owner: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hostname_cloak: Option<String>,
}
```

**Step 2: Set hostname_cloak in bouncer creation**

In `crates/web-api/src/routes/bouncer.rs`, update the CR creation:

```rust
let bouncer = SojuBouncer::new(&name, SojuBouncerSpec {
    hostname: format!("{name}.irc.now"),
    owner: user.sub.clone(),
    hostname_cloak: Some(format!("{name}.irc.now")),
});
```

**Step 3: Run web-api tests**

Run: `cargo test -p web-api`
Expected: all pass

**Step 4: Commit**

```bash
git add crates/web-api/src/k8s.rs crates/web-api/src/routes/bouncer.rs
git commit -m "feat(web-api): set hostname_cloak when creating bouncer"
```

---

### Task 9: Build and deploy operator + update existing bouncers

**Step 1: Build and deploy the operator**

```bash
tar czf /tmp/build.tar.gz --exclude='target' --exclude='.git' \
  --exclude='irc-now-landing-page' --exclude='status' --exclude='design' \
  --exclude='./docs' --exclude='notes' Cargo.toml Cargo.lock crates/
oc start-build soju-operator --from-archive=/tmp/build.tar.gz -n irc-josie-cloud --follow
```

Expected: build completes

**Step 2: Regenerate and apply the CRD**

Run: `cargo run -p soju-operator -- crd | oc apply -f -`
(or however the CRD is generated/applied -- check the operator's main.rs)

**Step 3: Restart the operator**

Run: `oc rollout restart deployment/soju-operator -n irc-josie-cloud`

**Step 4: Patch existing bouncer CRs with hostname_cloak**

For each existing bouncer, patch it to add hostname_cloak:

```bash
oc patch sojubouncer <name> -n irc-josie-cloud --type merge \
  -p '{"spec":{"hostnameCloak":"<name>.irc.now"}}'
```

**Step 5: Verify soju config**

Run: `oc get configmap <name>-config -n irc-josie-cloud -o jsonpath='{.data.config}'`
Expected: config includes `hostname-cloak <name>.irc.now`

**Step 6: Verify whois**

Connect to the bouncer and run `/whois <nick>`. Should show `<name>.irc.now` instead of the IP.

---

### Task 10: Build and deploy web-api

**Step 1: Build web-api**

```bash
tar czf /tmp/build.tar.gz --exclude='target' --exclude='.git' \
  --exclude='irc-now-landing-page' --exclude='status' --exclude='design' \
  --exclude='./docs' --exclude='notes' Cargo.toml Cargo.lock crates/
oc start-build web-api --from-archive=/tmp/build.tar.gz -n irc-josie-cloud --follow
```

**Step 2: Restart web-api**

Run: `oc rollout restart deployment/web-api -n irc-josie-cloud`

**Step 3: Verify new bouncers get hostname_cloak**

Create a test bouncer via the portal and check the CR:

Run: `oc get sojubouncer -n irc-josie-cloud -o yaml`
Expected: new bouncers have `hostnameCloak` set
