Articles on: ggRock

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:

  1. The client POSTs to /hubs/clientHub/negotiate.
  2. Nginx returns 301 to the canonical hostname.
  3. The client follows the redirect, downgrading POST to GET.
  4. The GET hits the negotiate endpoint, which rejects non-POST with 405.
  5. 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.

The canonical hostname works because it matches 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

301 Moved Permanently + Location:

Hitting the catch-all redirect — this is the problem

POST via IP, followed (-L)

405 Method Not Allowed

Redirect downgraded POST to GET

POST via hostname

401 Unauthorized + WWW-Authenticate: Bearer

Healthy — reached the backend; auth is simply missing

POST via hostname

301 or 405

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.

Only one server block per port can be 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 {
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;
}
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).


Apply and verify

nginx -t && systemctl reload nginx

Re-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_server covers IP / no-SNI clients regardless.
A package update can overwrite the bundled Nginx config and re-introduce a catch-all redirect. Keep the corrected config saved so you can re-apply it, or place your changes where updates won't overwrite them, and re-test client connectivity after any ggRock update.

Updated on: 26/06/2026

Was this article helpful?

Share your feedback

Cancel

Thank you!