ggRock client fails to connect to the server (SignalR negotiate returns 405)
ggRock client fails to connect to the server (SignalR negotiate returns 405)
A redirect in the ggRock server's Nginx config can stop the ggRock client from communicating with the server, which appears as the client failing to start and looping. This article explains why it happens, how to confirm it, and how to fix it.
Symptoms
- The ggRock client fails to start and immediately shuts down, looping on every boot.
- The client cannot communicate with the ggRock server, so any function that depends on that connection stops working.
- The client log shows a SignalR negotiation failure against the hub, for example:
[ERR] Failed to start connection. Error getting negotiation response from "https://<SERVER>/hubs/clientHub".
System.Net.Http.HttpRequestException: Response status code does not indicate success: 405 (Method Not Allowed).
...
The HostOptions.BackgroundServiceExceptionBehavior is configured to StopHost ... the IHost instance is stopping.
[INF] Application is shutting down...
The 405 on the negotiate request is the key signal. Because the host is set to StopHost on a background-service exception, the whole client process exits and keeps retrying.
Why it happens
SignalR's negotiate endpoint only accepts POST. When a client follows an HTTP 301/302 redirect, it downgrades the request from POST to GET (only 307/308 preserve the method). The result:
- The client POSTs to
/hubs/clientHub/negotiate. - Nginx returns 301 to the canonical hostname.
- The client follows the redirect, downgrading POST to GET.
- The GET hits the negotiate endpoint, which rejects non-POST with 405.
- The client crashes and restarts.
This is triggered when the client connects to the server by IP address. An IP connection sends no SNI and a Host: header of the IP, so Nginx does not match the named server block and falls through to a catch-all / default_server block. If that block performs a redirect (commonly an "IP/alt-host → canonical hostname" redirect), every IP-connecting client is affected.
server_name and SNI, so Nginx routes it straight to the backend instead of the redirect block.Confirm the cause
Run these from an affected client. Use the server's IP for the first test and the canonical hostname for the second.
# By IP (no SNI) - reproduces what the client does
curl.exe -k -i -X POST "https://<SERVER_IP>/hubs/clientHub/negotiate?negotiateVersion=1"
# By hostname - what a healthy path looks like
curl.exe -i -X POST "https://ggrock.example.com/hubs/clientHub/negotiate?negotiateVersion=1"
Interpret the responses:
Request | Response | Meaning |
|---|---|---|
POST via IP | | Hitting the catch-all redirect — this is the problem |
POST via IP, followed ( | | Redirect downgraded POST to GET |
POST via hostname | | Healthy — reached the backend; auth is simply missing |
POST via hostname | | Still misrouted; recheck the config |
A healthy negotiate endpoint answers with 401 (or a JSON payload), never 301 or 405.
-k is required when testing by IP because the certificate is issued for the hostname, not the IP. Use the hostname (and drop -k) to also validate the certificate.Fix
Make the real ggRock server block the default_server so IP / no-SNI connections are proxied to the backend instead of redirected, and remove the catch-all redirect.
Before — a catch-all redirect block (the cause):
# Catch-all: any connection whose SNI matches no server_name lands here.
# IP / no-SNI clients get redirected, which breaks the SignalR POST.
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
include snippets/ggrock-cert.conf;
return 301 https://ggrock.example.com$request_uri;
}
After — move default_server onto the canonical server block:
server {
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
server_name ggrock.example.com;
include snippets/ggrock-cert.conf;
# ... existing ssl_*, gzip, client_max_body_size, etc. ...
location / {
proxy_pass http://localhost:5000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
}
location /hubs {
proxy_pass http://localhost:5000/hubs;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
Then delete the server_name _; catch-all block.
default_server. Two default_server entries on 443 will stop Nginx from starting.If you still want an alternate hostname to redirect to the canonical one, don't keep it as the catch-all — convert it to a named block so it only fires for that specific name:
server {Over HTTPS, the certificate is presented during the TLS handshake before the 301 can be sent. If the cert is not valid for the alternate name, that redirect will throw a certificate warning first. The redirect is clean over plain HTTP (port 80).
listen 443 ssl;
listen [::]:443 ssl;
server_name ggrock-alt.example.com;
include snippets/ggrock-cert.conf;
return 301 https://ggrock.example.com$request_uri;
}
Apply and verify
nginx -t && systemctl reload nginxRe-test the negotiate endpoint by IP — it should now return 401, not 301/405:
curl.exe -k -i -X POST "https://<SERVER_IP>/hubs/clientHub/negotiate?negotiateVersion=1"Restart the ggRock client (or reboot the machine). The shutdown loop should stop and the client should connect and resume communicating with the server.
Notes and prevention
- This is a connectivity failure at its root. Any operation that depends on the client communicating with the ggRock server (machine management, status reporting, domain join, and other server-mediated functions) will fail until the connection is restored. Fixing the client's connection restores those functions.
- The most durable fix is to point clients at the hostname the certificate is valid for. When clients use the hostname, the redirect never applies. Making the canonical block the
default_servercovers IP / no-SNI clients regardless.
Updated on: 26/06/2026
Thank you!
