Route Google API calls through remote agent with insecure TLS

- validateAddress and getTimeZone now use proxyRequest instead of native
  fetch so the request originates from the on-prem IP, satisfying the
  API_KEY_IP_ADDRESS_BLOCKED restriction on the Google key
- Both calls carry insecure:true because the agent's network path runs
  SSL-inspecting proxies that substitute an internal-CA chain (throws
  SELF_SIGNED_CERT_IN_CHAIN otherwise); scoped per-request, consistent
  with SIW
- Fix latent bug in getTimeZone: use GET (per Google docs) instead of POST
- Rename README section to "Remote agent (SIW + Google)" and document
  the SSL-inspection nuance

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-06 15:06:58 -04:00
parent 93b060bc8b
commit a05dbb2733
2 changed files with 57 additions and 16 deletions

View file

@ -10,8 +10,9 @@ greetings, build auto-attendants, and clean up user licensing.
- A Webex bot token, a Webex integration (service account) with the scopes
currently used by admin API calls, a Twilio lookup account, an SIW basic-auth
user, and a Google API key with Address Validation + Time Zone enabled.
- An on-prem host that can reach Store Info Web, to run the remote agent
(see [Remote SIW agent](#remote-siw-agent) below).
- An on-prem host that can reach Store Info Web and Google APIs from an
IP-whitelisted subnet, to run the remote agent (see
[Remote agent (SIW + Google)](#remote-agent-siw--google) below).
- Optional: a Google service-account JSON key. Only the REST API key is
required today, but if/when you add code that uses `google-auth-library`,
save the JSON at `config/google-service-account.json` and set
@ -57,21 +58,35 @@ The volume mount preserves `config/wbxTokens.json` across restarts so the
refresh cron doesn't lose state. The `-p 8080:8080` publishes the WebSocket
port so the remote SIW agent can connect back to the bot.
## Remote SIW agent
## Remote agent (SIW + Google)
Store Info Web only accepts connections from inside the corporate network,
but the bot runs in the cloud. To bridge the gap, the bot hosts a
WebSocket server; a small on-prem agent dials in and proxies HTTP requests
back and forth.
Some services can only be reached from inside the corporate network:
- **Store Info Web** — only accepts connections from internal IPs, and
presents an internal-CA TLS cert Node's default trust store doesn't know
about.
- **Google Maps / Address Validation** — the API key is IP-restricted, so
requests direct from the cloud bot IP get `API_KEY_IP_ADDRESS_BLOCKED
(403)`.
To bridge the gap, the bot hosts a WebSocket server; a small on-prem agent
dials in and proxies HTTP requests back and forth. Both SIW and Google
calls go over this bridge so they originate from the on-prem IP.
The agent itself is intentionally generic — it just proxies whatever
`{method, url, headers, auth, body}` payload arrives — and lives in
[`docker/remote-agent/`](docker/remote-agent) as a self-contained Docker
bundle you can build here and ship to the on-prem host.
`{method, url, headers, auth, body, insecure?}` payload arrives — and
lives in [`docker/remote-agent/`](docker/remote-agent) as a self-contained
Docker bundle you can build here and ship to the on-prem host. The
`insecure: true` flag is scoped per request. Both SIW and Google set it,
because the corporate network the agent lives on runs SSL-inspecting
proxies that intercept outbound HTTPS with an internal-CA chain — without
the flag Node throws `SELF_SIGNED_CERT_IN_CHAIN` even for Google's public
certs. Trust is delegated to that proxy by policy, so the scoped bypass is
consistent across all proxied traffic.
Once the agent is connected, the bot logs `Remote agent connected` and any
`/buildStore`, `/stageStore`, `/migrateStore` command will succeed. If the
agent is not connected, the SIW-dependent commands fail immediately with
agent is not connected, those commands fail immediately with
`No remote SIW agent connected` rather than silently timing out.
### Deploying the agent

View file

@ -1,8 +1,21 @@
import { config } from '../config.js';
import { requestJson } from '../http.js';
import { proxyRequest } from '../services/websocket.js';
// All Google API calls are routed through the on-prem remote agent so the
// request originates from the whitelisted IP subnet. The API key is
// restricted by IP; calls direct from the cloud bot IP get 403 with
// API_KEY_IP_ADDRESS_BLOCKED. The remote agent runs inside the corporate
// network, so its egress IP matches the key's allow-list.
//
// `insecure: true` is set even though Google's public certs are trusted,
// because the corporate network the agent lives on intercepts outbound
// HTTPS with an SSL-inspecting proxy that presents an internal-CA chain.
// Without the flag Node throws SELF_SIGNED_CERT_IN_CHAIN on any outbound
// TLS. Trust is already delegated to that proxy by being on this network,
// so the scoped per-request bypass is consistent with SIW.
/**
* Google Address Validation API. Returns the raw response body.
* Google Address Validation API. Returns the parsed response body.
*/
export async function validateAddress(street, city, state, postalCode, country) {
const body = {
@ -15,18 +28,31 @@ export async function validateAddress(street, city, state, postalCode, country)
},
};
const url = `https://addressvalidation.googleapis.com/v1:validateAddress?key=${encodeURIComponent(config.google.apiKey)}`;
return requestJson('POST', url, body);
const response = await proxyRequest({
method: 'POST',
url,
headers: { 'Content-Type': 'application/json' },
body,
insecure: true,
});
return response?.data;
}
/**
* Google Time Zone API for a given lat/lon at "now".
* Google Time Zone API for a given lat/lon at "now". Uses GET as documented
* by Google parameters are entirely in the query string.
*/
export async function getTimeZone(latitude, longitude) {
const timestamp = Math.floor(Date.now() / 1000);
const url =
`https://maps.googleapis.com/maps/api/timezone/json?location=${latitude}%2C${longitude}` +
`&timestamp=${timestamp}&key=${encodeURIComponent(config.google.apiKey)}`;
return requestJson('POST', url);
const response = await proxyRequest({
method: 'GET',
url,
insecure: true,
});
return response?.data;
}
/**