@@ -181,110 +181,126 @@ def rpc(
181181 }
182182 json_request = request (method , args )
183183
184- # Determine SSL verification mode
185- # Options:
186- # - ssl_cert: path to a trusted certificate file (for self-signed certs)
187- # - verify_ssl: false to disable verification entirely (last resort)
188- #
189- # For IP addresses with ssl_cert: We skip SSL verification because:
190- # 1. Standard verification fails (cert is for hostname, not IP)
191- # 2. The certificate was fetched via SSH (trusted channel) during init
192- # 3. We're connecting to the same server we SSH'd into
193- parsed_url = urlparse (self .api_url )
194- verify_ssl : bool | str = True
195-
196- if parsed_url .scheme == "https" :
197- ssl_cert = self .config .get ("ssl_cert" , None )
198- verify_ssl_config = self .config .get ("verify_ssl" , None )
199- hostname = parsed_url .hostname or ""
200-
201- # Parse verify_ssl setting
202- verify_ssl_disabled = False
203- if verify_ssl_config is not None :
204- if isinstance (verify_ssl_config , str ):
205- verify_ssl_disabled = verify_ssl_config .lower () in (
206- "false" ,
207- "0" ,
208- "no" ,
209- )
210- else :
211- verify_ssl_disabled = not bool (verify_ssl_config )
212-
213- if ssl_cert and _is_ip_address (hostname ):
214- # IP address with saved cert: skip verification
215- # (cert was fetched via trusted SSH, hostname check would fail)
216- verify_ssl = False
217- elif ssl_cert :
218- # Hostname with saved cert: standard verification with custom CA
219- verify_ssl = ssl_cert
220- elif verify_ssl_disabled :
221- # No cert and verification explicitly disabled
222- verify_ssl = False
223- # else: default to True (system CA bundle)
224- else :
225- verify_ssl = False
226-
227- # Build headers with authentication
228- headers = {"Content-Type" : "application/json" }
229-
230- # Add authentication token if configured
231- # Note: Even with SSH tunnel, we still use token for authorization
232- # SSH provides transport security, token provides authorization
233- api_token = self .config .get ("api_token" , "" )
234- if api_token :
235- headers ["Authorization" ] = f"Bearer { api_token } "
184+ verify_ssl = self ._get_ssl_verification ()
185+ headers = self ._build_headers ()
236186
237187 response = requests .post (
238188 self .rpc_url ,
239189 json = json_request ,
240190 headers = headers ,
241191 verify = verify_ssl ,
242192 )
193+
194+ return self ._parse_response (response , json_request )
195+
196+ def _get_ssl_verification (self ) -> bool | str :
197+ """Determine SSL verification mode based on config."""
198+ parsed_url = urlparse (self .api_url )
199+
200+ if parsed_url .scheme != "https" :
201+ return False
202+
203+ ssl_cert = self .config .get ("ssl_cert" , None )
204+ verify_ssl_config = self .config .get ("verify_ssl" , None )
205+ hostname = parsed_url .hostname or ""
206+
207+ # Check if verification is explicitly disabled
208+ verify_ssl_disabled = self ._is_verify_ssl_disabled (verify_ssl_config )
209+
210+ if ssl_cert and _is_ip_address (hostname ):
211+ # IP address with saved cert: skip verification
212+ # (cert was fetched via trusted SSH, hostname check would fail)
213+ return False
214+ if ssl_cert :
215+ # Hostname with saved cert: standard verification with custom CA
216+ return ssl_cert
217+
218+ # Default to system CA bundle unless explicitly disabled
219+ return not verify_ssl_disabled
220+
221+ def _is_verify_ssl_disabled (self , verify_ssl_config : str | bool | None ) -> bool :
222+ """Check if SSL verification is explicitly disabled in config."""
223+ if verify_ssl_config is None :
224+ return False
225+ if isinstance (verify_ssl_config , str ):
226+ return verify_ssl_config .lower () in ("false" , "0" , "no" )
227+ return not bool (verify_ssl_config )
228+
229+ def _build_headers (self ) -> dict [str , str ]:
230+ """Build request headers with authentication."""
231+ headers = {"Content-Type" : "application/json" }
232+ api_token = self .config .get ("api_token" , "" )
233+ if api_token :
234+ headers ["Authorization" ] = f"Bearer { api_token } "
235+ return headers
236+
237+ def _parse_response (
238+ self , response : requests .Response , json_request : dict
239+ ) -> Response :
240+ """Parse HTTP response into JSON-RPC response."""
241+ request_id = json_request ["id" ]
242+
243243 try :
244- # Check for 401 Unauthorized specifically
245244 if response .status_code == 401 :
246- error_msg = (
247- "Authentication required.\n \n "
248- "To authenticate, use one of the following methods:\n "
249- " 1. Login: hop auth:login <username> <password>\n "
250- " 2. Register: hop auth:register <username> <email> <password>\n \n "
251- "After logging in, save the token to ~/.config/hop3-cli/config.toml\n "
252- "or set the HOP3_API_TOKEN environment variable."
253- )
254- return Error (401 , error_msg , "" , json_request ["id" ])
245+ return self ._make_auth_error (request_id )
246+
247+ # Try to parse as JSON-RPC response (even for non-200 status codes)
248+ json_rpc_response = self ._try_parse_jsonrpc (response , request_id )
249+ if json_rpc_response is not None :
250+ return json_rpc_response
255251
252+ # For non-200 responses without JSON-RPC error, raise HTTP error
256253 response .raise_for_status ()
257- parsed_response = parse (response .json ())
258- # parse() can return Error, Ok, or Iterable[Error | Ok], but we expect single response
254+ return Error (- 1 , "Unexpected response format" , "" , request_id )
255+
256+ except requests .exceptions .HTTPError as e :
257+ error_detail = f"HTTP { response .status_code } error: { e !s} "
258+ if response .text :
259+ error_detail += f"\n Response: { response .text [:500 ]} "
260+ return Error (response .status_code , error_detail , "" , request_id )
261+ except Exception as e :
262+ return Error (response .status_code , str (e ), "" , request_id )
263+
264+ def _make_auth_error (self , request_id : int ) -> Error :
265+ """Create authentication required error."""
266+ error_msg = (
267+ "Authentication required.\n \n "
268+ "To authenticate, use one of the following methods:\n "
269+ " 1. Login: hop auth:login <username> <password>\n "
270+ " 2. Register: hop auth:register <username> <email> <password>\n \n "
271+ "After logging in, save the token to ~/.config/hop3-cli/config.toml\n "
272+ "or set the HOP3_API_TOKEN environment variable."
273+ )
274+ return Error (401 , error_msg , "" , request_id )
275+
276+ def _try_parse_jsonrpc (
277+ self , response : requests .Response , request_id : int
278+ ) -> Response | None :
279+ """Try to parse response as JSON-RPC. Returns None if not valid JSON-RPC."""
280+ try :
281+ json_body = response .json ()
282+ except ValueError :
283+ return None
284+
285+ # Check if this is a JSON-RPC error response
286+ if "error" in json_body and isinstance (json_body ["error" ], dict ):
287+ rpc_error = json_body ["error" ]
288+ return Error (
289+ rpc_error .get ("code" , response .status_code ),
290+ rpc_error .get ("message" , "Unknown error" ),
291+ rpc_error .get ("data" , "" ),
292+ request_id ,
293+ )
294+
295+ # Parse successful JSON-RPC response
296+ if response .ok :
297+ parsed_response = parse (json_body )
259298 if isinstance (parsed_response , (Error , Ok )):
260299 return parsed_response
261300 # Handle batch responses - take first response
262301 responses = list (parsed_response )
263302 if responses and isinstance (responses [0 ], (Error , Ok )):
264303 return responses [0 ]
265- return Error (- 1 , "Invalid response format" , "" , json_request ["id" ])
266- except requests .exceptions .HTTPError as e :
267- # For other HTTP errors, provide the status code and message
268- # Try to extract error details from response body
269- error_detail = f"HTTP { response .status_code } error: { e !s} "
270- try :
271- error_body = response .json ()
272- if "detail" in error_body :
273- error_detail += f"\n Detail: { error_body ['detail' ]} "
274- elif "error" in error_body :
275- error_detail += f"\n Error: { error_body ['error' ]} "
276- else :
277- error_detail += f"\n Response: { error_body } "
278- except Exception :
279- # If we can't parse JSON, try to show raw response
280- if response .text :
281- error_detail += f"\n Response: { response .text [:500 ]} "
304+ return Error (- 1 , "Invalid response format" , "" , request_id )
282305
283- return Error (
284- response .status_code ,
285- error_detail ,
286- "" ,
287- json_request ["id" ],
288- )
289- except Exception as e :
290- return Error (response .status_code , str (e ), "" , json_request ["id" ])
306+ return None
0 commit comments