# irc.now Network (net.irc.now) Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Deploy an Ergo IRC server at net.irc.now with Keycloak SSO and wire it as the default upstream for all soju bouncers. **Architecture:** The existing ergo-operator manages ErgoNetwork CRDs. We add OAuth2 support to its CRD and configmap generation, deploy the operator and an ErgoNetwork CR, create a Keycloak client, then update web-api to insert net.irc.now as a default upstream in each bouncer's soju database. **Tech Stack:** Rust (ergo-operator, web-api), Ergo IRC (Go), Keycloak, PostgreSQL, OCP --- ### Task 1: Add OAuth2 spec to ErgoNetwork CRD **Files:** - Modify: `crates/ergo-operator/src/types.rs:17-33` - Modify: `crates/ergo-operator/src/testutil.rs` **Step 1: Write the failing test** In `crates/ergo-operator/src/types.rs`, add to the existing tests module: ```rust #[test] fn default_spec_has_no_oauth2() { let spec = ErgoNetworkSpec::default(); assert!(spec.oauth2.is_none()); } ``` **Step 2: Run test to verify it fails** Run: `cargo test -p ergo-operator default_spec_has_no_oauth2` Expected: FAIL -- no field `oauth2` on `ErgoNetworkSpec` **Step 3: Add the oauth2 field and types** In `crates/ergo-operator/src/types.rs`, add after line 32 (before the closing brace of `ErgoNetworkSpec`): ```rust #[serde(default)] pub oauth2: Option, ``` Add the OAuth2Spec struct after `RouteSpec`: ```rust #[derive(Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] pub struct OAuth2Spec { pub introspection_url: String, pub secret_ref: String, #[serde(default = "default_true")] pub autocreate: bool, } fn default_true() -> bool { true } ``` **Step 4: Update test fixture** In `crates/ergo-operator/src/testutil.rs`, add to `test_network()` spec: ```rust oauth2: Some(OAuth2Spec { introspection_url: "https://auth.irc.now/realms/irc-now/protocol/openid-connect/token/introspect".to_string(), secret_ref: "oidc-ergo".to_string(), autocreate: true, }), ``` **Step 5: Run tests** Run: `cargo test -p ergo-operator` Expected: all pass **Step 6: Commit** ```bash git add crates/ergo-operator/src/types.rs crates/ergo-operator/src/testutil.rs git commit -m "feat(ergo-operator): add oauth2 spec to ErgoNetwork CRD" ``` --- ### Task 2: Add IP cloaking spec to ErgoNetwork CRD **Files:** - Modify: `crates/ergo-operator/src/types.rs` - Modify: `crates/ergo-operator/src/testutil.rs` **Step 1: Write the failing test** ```rust #[test] fn default_spec_has_no_cloaking() { let spec = ErgoNetworkSpec::default(); assert!(spec.cloaking.is_none()); } ``` **Step 2: Run test to verify it fails** Run: `cargo test -p ergo-operator default_spec_has_no_cloaking` Expected: FAIL **Step 3: Add the cloaking field and type** In `ErgoNetworkSpec`, add: ```rust #[serde(default)] pub cloaking: Option, ``` Add the struct: ```rust #[derive(Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] pub struct CloakingSpec { pub netname: String, } ``` **Step 4: Update test fixture** In `test_network()`, add: ```rust cloaking: Some(CloakingSpec { netname: "irc.now".to_string(), }), ``` **Step 5: Run tests** Run: `cargo test -p ergo-operator` Expected: all pass **Step 6: Commit** ```bash git add crates/ergo-operator/src/types.rs crates/ergo-operator/src/testutil.rs git commit -m "feat(ergo-operator): add cloaking spec to ErgoNetwork CRD" ``` --- ### Task 3: Emit OAuth2 config in ergo configmap **Files:** - Modify: `crates/ergo-operator/src/resources/configmap.rs:26-75` - Modify: `crates/ergo-operator/src/controller.rs` **Step 1: Write the failing test** In `crates/ergo-operator/src/resources/configmap.rs`, add: ```rust #[test] fn config_contains_oauth2_when_configured() { let cm = build_configmap(&test_network(), None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(config.contains("oauth2")); assert!(config.contains("introspection")); assert!(config.contains("autocreate: true")); } ``` **Step 2: Run test to verify it fails** Run: `cargo test -p ergo-operator config_contains_oauth2_when_configured` Expected: FAIL -- `build_configmap` takes wrong number of args **Step 3: Update build_configmap signature and implementation** Change `build_configmap` to accept an optional OAuth2 client secret: ```rust pub fn build_configmap( network: &ErgoNetwork, oper_hash: Option<&str>, oauth2_secret: Option<&str>, ) -> ConfigMap { ``` In `generate_ergo_config`, add the same parameter and after the opers section: ```rust fn generate_ergo_config( network: &ErgoNetwork, oper_hash: Option<&str>, oauth2_secret: Option<&str>, ) -> String { ``` After the `opers` block (line 72), add: ```rust if let Some(oauth2) = &spec.oauth2 { if let Some(secret) = oauth2_secret { config["accounts"] = serde_json::json!({ "oauth2": { "enabled": true, "autocreate": oauth2.autocreate, "introspection-url": oauth2.introspection_url, "introspection-timeout": "10s", "client-id": "ergo", "client-secret": secret, } }); } } ``` **Step 4: Update all existing test calls** Update all `build_configmap(&test_network(), None)` calls to `build_configmap(&test_network(), None, None)` and the oauth2 test to pass a secret: ```rust #[test] fn config_contains_oauth2_when_configured() { let cm = build_configmap(&test_network(), None, Some("test-secret")); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(config.contains("oauth2")); assert!(config.contains("introspection")); assert!(config.contains("autocreate: true")); } #[test] fn config_omits_oauth2_when_not_configured() { let mut network = test_network(); network.spec.oauth2 = None; let cm = build_configmap(&network, None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(!config.contains("oauth2")); } ``` **Step 5: Update controller.rs** In `controller.rs`, update the `build_configmap` call (around line 91) to pass the oauth2 secret. Read it from the secret referenced in `spec.oauth2.secret_ref`: Add a `read_oauth2_secret` function similar to `read_oper_hash`: ```rust async fn read_oauth2_secret( client: &kube::Client, ns: &str, network: &ErgoNetwork, ) -> Result, Error> { let oauth2 = match &network.spec.oauth2 { Some(o) => o, None => return Ok(None), }; let secret_api: Api = Api::namespaced(client.clone(), ns); let secret = secret_api .get(&oauth2.secret_ref) .await .map_err(Error::Kube)?; if let Some(data) = &secret.data { if let Some(bytes) = data.get("client-secret") { return Ok(Some(String::from_utf8_lossy(&bytes.0).to_string())); } } Ok(None) } ``` Update the reconcile call: ```rust let oauth2_secret = read_oauth2_secret(client, ns, network).await?; let cm = build_configmap(network, oper_hash.as_deref(), oauth2_secret.as_deref()); ``` **Step 6: Run tests** Run: `cargo test -p ergo-operator` Expected: all pass **Step 7: Commit** ```bash git add crates/ergo-operator/src/resources/configmap.rs crates/ergo-operator/src/controller.rs git commit -m "feat(ergo-operator): emit oauth2 config in ergo configmap" ``` --- ### Task 4: Emit IP cloaking config in ergo configmap **Files:** - Modify: `crates/ergo-operator/src/resources/configmap.rs` **Step 1: Write the failing test** ```rust #[test] fn config_contains_cloaking_when_configured() { let cm = build_configmap(&test_network(), None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(config.contains("ip-cloaking")); assert!(config.contains("irc.now")); } #[test] fn config_omits_cloaking_when_not_configured() { let mut network = test_network(); network.spec.cloaking = None; let cm = build_configmap(&network, None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(!config.contains("ip-cloaking")); } ``` **Step 2: Run test to verify it fails** Run: `cargo test -p ergo-operator config_contains_cloaking` Expected: FAIL **Step 3: Add cloaking emission** In `generate_ergo_config`, after the oauth2 block, add: ```rust if let Some(cloaking) = &spec.cloaking { let server = config["server"].as_object_mut().unwrap(); server.insert("ip-cloaking".to_string(), serde_json::json!({ "enabled": true, "enabled-for-always-on": true, "netname": cloaking.netname, "cidr-len-ipv4": 32, "cidr-len-ipv6": 64, "num-bits": 64, })); } ``` **Step 4: Run tests** Run: `cargo test -p ergo-operator` Expected: all pass **Step 5: Commit** ```bash git add crates/ergo-operator/src/resources/configmap.rs git commit -m "feat(ergo-operator): emit ip-cloaking config in ergo configmap" ``` --- ### Task 5: Regenerate CRD and commit ergo-operator **Step 1: Regenerate the CRD YAML** Run: `cargo run -p ergo-operator --bin crdgen > crates/ergo-operator/deploy/crd.yaml` **Step 2: Run all tests** Run: `cargo test -p ergo-operator` Expected: all pass **Step 3: Commit everything** ```bash git add crates/ergo-operator/ git commit -m "feat: add ergo-operator for IRC network management" ``` --- ### Task 6: Deploy ergo-operator to cluster **Step 1: Apply RBAC and CRD** ```bash oc apply -f crates/ergo-operator/deploy/rbac.yaml oc apply -f crates/ergo-operator/deploy/crd.yaml ``` **Step 2: Build the operator image** ```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 apply -f crates/ergo-operator/deploy/buildconfig.yaml oc start-build ergo-operator --from-archive=/tmp/build.tar.gz -n irc-josie-cloud --follow ``` **Step 3: Deploy the operator** ```bash oc apply -f crates/ergo-operator/deploy/operator.yaml ``` **Step 4: Verify** Run: `oc get pods -n irc-josie-cloud -l app=ergo-operator` Expected: 1/1 Running --- ### Task 7: Create Keycloak client and OCP secret **Step 1: Create Keycloak client `ergo`** Via Keycloak admin console at https://auth.irc.now/admin (realm: irc-now): - Client ID: `ergo` - Client authentication: ON (confidential) - Service accounts roles: enabled (for token introspection) - Valid redirect URIs: (none needed -- server-side only) Copy the client secret from the Credentials tab. **Step 2: Create OCP secret** ```bash oc create secret generic oidc-ergo -n irc-josie-cloud \ --from-literal=client-secret= ``` **Step 3: Verify** Run: `oc get secret oidc-ergo -n irc-josie-cloud` Expected: secret exists --- ### Task 8: Set up DNS and create ErgoNetwork CR **Step 1: Create DNS A record** Add `net.irc.now` A record in Cloudflare pointing to the cluster ingress IP (same IP as other irc.now services). Check existing: ```bash dig irc.now +short ``` Use that IP for the new record. **Step 2: Create the ErgoNetwork CR** ```yaml apiVersion: irc.josie.cloud/v1alpha1 kind: ErgoNetwork metadata: name: irc-now-net namespace: irc-josie-cloud spec: hostname: net.irc.now networkName: irc.now listeners: - address: ":6697" tls: true - address: ":6667" tls: false tls: issuerRef: name: letsencrypt-prod kind: ClusterIssuer dnsNames: - net.irc.now route: host: net.irc.now oauth2: introspection_url: "https://auth.irc.now/realms/irc-now/protocol/openid-connect/token/introspect" secret_ref: oidc-ergo autocreate: true cloaking: netname: irc.now storage: size: 1Gi storageClassName: customer-workload-storage resources: requests: memory: "64Mi" cpu: "50m" limits: memory: "256Mi" cpu: "200m" ``` Save to `crates/ergo-operator/deploy/irc-now-net.yaml` and apply: ```bash oc apply -f crates/ergo-operator/deploy/irc-now-net.yaml ``` **Step 3: Create the PV for ergo storage** The `customer-workload-storage` StorageClass uses hostPath PVs. Create the directory on the node and the PV: ```bash ssh root@ "mkdir -p /var/data/irc-josie-cloud/irc-now-net-data && chcon -R system_u:object_r:container_file_t:s0 /var/data/irc-josie-cloud/irc-now-net-data && chown -R 1001260000:1001260000 /var/data/irc-josie-cloud/irc-now-net-data" ``` Create the PV (adjust node name): ```yaml apiVersion: v1 kind: PersistentVolume metadata: name: irc-now-net-data spec: capacity: storage: 1Gi accessModes: [ReadWriteOnce] storageClassName: customer-workload-storage hostPath: path: /var/data/irc-josie-cloud/irc-now-net-data nodeAffinity: required: nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/hostname operator: In values: [""] ``` **Step 4: Set up TLS route cert RBAC** Same pattern as other services -- grant the router SA access to the TLS secret: ```yaml apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: net-irc-now-tls-reader namespace: irc-josie-cloud rules: - apiGroups: [""] resources: ["secrets"] resourceNames: ["irc-now-net-tls"] verbs: ["get"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: net-irc-now-tls-reader namespace: irc-josie-cloud subjects: - kind: ServiceAccount name: router namespace: openshift-ingress roleRef: kind: Role name: net-irc-now-tls-reader apiGroup: rbac.authorization.k8s.io ``` **Step 5: Verify Ergo is running** ```bash oc get ergo -n irc-josie-cloud oc get pods -n irc-josie-cloud -l app.kubernetes.io/name=irc-now-net oc logs deployment/irc-now-net -n irc-josie-cloud ``` Expected: ErgoNetwork shows Ready=True, pod is 1/1 Running **Step 6: Commit the CR** ```bash git add crates/ergo-operator/deploy/irc-now-net.yaml git commit -m "feat(ergo-operator): add irc-now-net ErgoNetwork CR" ``` --- ### Task 9: Wire default upstream in web-api **Files:** - Modify: `crates/web-api/src/routes/bouncer.rs:54-89` - Modify: `crates/web-api/src/state.rs` **Step 1: Add soju DB connection info to AppState** In `crates/web-api/src/state.rs`, add: ```rust pub soju_db_host: String, pub soju_db_port: u16, ``` Initialize from env vars `SOJU_DB_HOST` and `SOJU_DB_PORT` in main.rs (same vars the soju-operator uses). **Step 2: Add default upstream insertion to bouncer creation** In `crates/web-api/src/routes/bouncer.rs`, after the bouncer CR is created (line 86), add a function that connects to the bouncer's soju DB and inserts the default network. The soju DB credentials are in the secret `{name}-db` (created by the soju-operator). Read the secret via kube client to get the password, then connect to the soju DB and insert: ```rust async fn add_default_upstream( state: &AppState, bouncer_name: &str, username: &str, ) -> Result<(), Box> { let secret_api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let secret = secret_api.get(&format!("{bouncer_name}-db")).await?; let password = secret .data .as_ref() .and_then(|d| d.get("password")) .map(|b| String::from_utf8_lossy(&b.0).to_string()) .ok_or("missing password in db secret")?; let db_name = format!("soju_{}", bouncer_name.replace('-', "_")); let db_user = db_name.clone(); let conn_str = format!( "host={} port={} user={} password={} dbname={} sslmode=disable", state.soju_db_host, state.soju_db_port, db_user, password, db_name ); let (client, connection) = tokio_postgres::connect(&conn_str, tokio_postgres::NoTls).await?; tokio::spawn(async move { connection.await }); // Wait for soju to create the user (it happens on first auth) // Insert network for user once they exist // For now, we'll use a retry or just insert directly since soju // auto-creates users on OAuth login client .execute( r#"INSERT INTO "Network" ("user", name, addr, nick, enabled) SELECT id, 'irc.now', 'irc-now-net:6697', $1, true FROM "User" WHERE username = $2 ON CONFLICT ("user", name) DO NOTHING"#, &[&bouncer_name, &username], ) .await?; Ok(()) } ``` Call it after bouncer creation, but don't fail the request if it errors (the user can add the network manually): ```rust if let Err(e) = add_default_upstream(&state, &name, &user.sub).await { tracing::warn!("failed to add default upstream: {e}"); } ``` Note: The soju user won't exist in the DB until they first authenticate. The insert will simply insert 0 rows in that case. The user will get net.irc.now added when they first log in via chat.irc.now and the OAuth creates their soju user, then a subsequent reconnect or manual add will pick it up. **Step 3: Add tokio-postgres dependency** In `crates/web-api/Cargo.toml`, add: ```toml tokio-postgres = "0.7" ``` **Step 4: Run tests** Run: `cargo test -p irc-now-web-api` Expected: all pass (or compilation succeeds -- web-api has minimal unit tests) **Step 5: Commit** ```bash git add crates/web-api/src/routes/bouncer.rs crates/web-api/src/state.rs crates/web-api/src/main.rs crates/web-api/Cargo.toml git commit -m "feat(web-api): add net.irc.now as default upstream on bouncer creation" ``` --- ### Task 10: Migrate existing bouncers and deploy **Step 1: Add net.irc.now to josie's soju DB** ```bash oc exec soju-db-1 -n irc-josie-cloud -- psql -U soju_josie -d soju_josie -c \ "INSERT INTO \"Network\" (\"user\", name, addr, nick, enabled) VALUES (1, 'irc.now', 'irc-now-net:6697', 'josie', true) ON CONFLICT DO NOTHING" ``` **Step 2: Build and deploy 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 oc rollout restart deployment/web-api -n irc-josie-cloud ``` **Step 3: Restart josie bouncer to pick up the new network** ```bash oc rollout restart deployment/josie -n irc-josie-cloud ``` **Step 4: Verify** Check josie's soju logs for a connection to irc-now-net: ```bash oc logs deployment/josie -n irc-josie-cloud | grep irc.now ``` Expected: `upstream "irc.now": connecting to TLS server at address "irc-now-net:6697"` **Step 5: Test whois on net.irc.now** Connect to the bouncer and switch to the irc.now network. Run `/whois josie`. Expected: hostname shows `.irc.now` instead of an IP.