Skip to content

feat: bypass proxy for private/LAN IPs in management api-call#1960

Open
thebtf wants to merge 1 commit intorouter-for-me:mainfrom
thebtf:fix/no-proxy-private-ips
Open

feat: bypass proxy for private/LAN IPs in management api-call#1960
thebtf wants to merge 1 commit intorouter-for-me:mainfrom
thebtf:fix/no-proxy-private-ips

Conversation

@thebtf
Copy link
Copy Markdown
Contributor

@thebtf thebtf commented Mar 8, 2026

Summary

  • When a global proxy is configured, the management api-call handler routes all requests through it — including requests to LAN targets (e.g. LM Studio at 172.x.x.x). Since proxies typically cannot reach private networks, these requests time out.
  • The Management UI "Pick Models from /models" feature becomes unusable for any LAN-hosted provider when a proxy is configured.
  • Adds isNoProxyHost() that detects RFC 1918, loopback, and link-local addresses and skips the proxy for them.
  • Only affects the management api-call handler; executor proxy logic is unchanged.

Default bypass ranges

127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1/128, fc00::/7, fe80::/10

Files changed

  • internal/api/handlers/management/api_tools.go

Test plan

  • Configure global proxy in config.yaml
  • Use Management UI "Pick Models" with a LAN target (e.g. http://192.168.1.100:1234/v1/models)
  • Verify models are fetched successfully (no timeout)
  • Verify external targets still route through proxy

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request enhances network request handling by introducing intelligent proxy bypass logic. It addresses a critical usability issue where the management API's api-call handler, when a global proxy is active, would incorrectly route requests to internal network resources through the proxy, leading to connection timeouts. By detecting private and local IP ranges, the system can now correctly route these requests directly, ensuring seamless interaction with LAN-hosted services like model providers in the Management UI.

Highlights

  • Proxy Bypass for Private/LAN IPs: Implemented a mechanism to bypass the configured global proxy for requests targeting private, loopback, and link-local IP addresses within the management api-call handler. This resolves timeouts experienced when accessing LAN-hosted services (e.g., LM Studio) through a proxy.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • internal/api/handlers/management/api_tools.go
    • Modified the apiCallTransport function signature to accept a targetHost parameter.
    • Introduced a new isNoProxyHost function to determine if a given host falls within predefined private IP ranges.
    • Defined defaultNoProxyCIDRs containing common private, loopback, and link-local CIDR blocks.
    • Updated existing calls to apiCallTransport to pass the relevant host information, enabling conditional proxy bypass.
Activity
  • No human activity has been recorded for this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a feature to bypass the configured proxy for requests to private/LAN IP addresses in the management API call handler. The security review agent noted that no specific changes were provided for review, preventing a detailed security critique. The implementation correctly modifies the apiCallTransport function to check the target host and conditionally skip proxying, and the changes in refreshGeminiOAuthAccessToken and refreshAntigravityOAuthAccessToken ensure external OAuth requests continue to use the proxy. A suggestion has been made to improve the efficiency and robustness of the new isNoProxyHost function by pre-parsing the CIDR ranges at startup.

Comment on lines +637 to +668
var defaultNoProxyCIDRs = []string{
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10",
}

// isNoProxyHost returns true when targetHost should bypass the proxy.
func isNoProxyHost(targetHost string) bool {
host := targetHost
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
for _, cidr := range defaultNoProxyCIDRs {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
if network.Contains(ip) {
return true
}
}
if h != nil && h.cfg != nil {
if proxyStr := strings.TrimSpace(h.cfg.ProxyURL); proxyStr != "" {
proxyCandidates = append(proxyCandidates, proxyStr)
return false
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of isNoProxyHost parses the hardcoded CIDR strings on every function call. This is inefficient as it can be done once at application startup.

Additionally, if one of the hardcoded CIDRs is invalid, net.ParseCIDR will return an error which is silently ignored with continue. For hardcoded values, it's better to fail fast at startup if they are invalid.

I suggest pre-parsing the CIDRs into a slice of *net.IPNet during initialization. This improves performance and makes the logic more robust.

var defaultNoProxyCIDRs = []string{
	"127.0.0.0/8",
	"10.0.0.0/8",
	"172.16.0.0/12",
	"192.168.0.0/16",
	"169.254.0.0/16",
	"::1/128",
	"fc00::/7",
	"fe80::/10",
}

var parsedNoProxyCIDRs = func() []*net.IPNet {
	cidrs := make([]*net.IPNet, 0, len(defaultNoProxyCIDRs))
	for _, cidr := range defaultNoProxyCIDRs {
		_, network, err := net.ParseCIDR(cidr)
		if err != nil {
			// Panic on startup if hardcoded CIDRs are invalid.
			panic(fmt.Sprintf("invalid hardcoded CIDR in defaultNoProxyCIDRs: %q: %v", cidr, err))
		}
		cidrs = append(cidrs, network)
	}
	return cidrs
}()

// isNoProxyHost returns true when targetHost should bypass the proxy.
func isNoProxyHost(targetHost string) bool {
	if targetHost == "" {
		return false
	}
	host := targetHost
	if h, _, err := net.SplitHostPort(host); err == nil {
		host = h
	}
	ip := net.ParseIP(host)
	if ip == nil {
		return false
	}
	for _, network := range parsedNoProxyCIDRs {
		if network.Contains(ip) {
			return true
		}
	}
	return false
}

Copy link
Copy Markdown

@xkonjin xkonjin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Overall: ✅ Approvable — correct approach, one robustness suggestion.

What's good

  • Bypassing proxy for RFC 1918 / loopback / link-local addresses is the right fix. These ranges are unreachable via most external proxies and routing them through one silently fails.
  • Passing targetHost explicitly rather than re-parsing inside the transport is clean.
  • Correctly passing "" for OAuth refresh calls (so they always use proxy — OAuth endpoints are always external).
  • IPv6 ranges (::1/128, fc00::/7, fe80::/10) included alongside IPv4 — thorough.

Issue: CIDR parsing on every request

isNoProxyHost calls net.ParseCIDR inside a loop on every invocation. With 8 CIDR strings this is fast but unnecessary work. Pre-parse defaultNoProxyCIDRs to []*net.IPNet at init time (package-level var with init() or a sync.Once) and just iterate the pre-parsed networks. This is what gemini-code-assist flagged and it's worth doing.

Minor notes

  • isNoProxyHost only matches IP addresses — hostname-based LAN targets (e.g. mymachine.local, raspberrypi) return false and will be proxied. This is probably acceptable for now but worth documenting as a known limitation in a comment.
  • No test for isNoProxyHost itself. Given it's a security boundary (bypasses proxy), a small table-driven test covering private IPs, public IPs, IPv6, and hostnames would be valuable.

Copy link
Copy Markdown

@xkonjin xkonjin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice direction, but there is still a gap in the no-proxy detection that will break common LAN setups.

isNoProxyHost() only bypasses the proxy when targetHost is already a literal IP (net.ParseIP(host) succeeds). For typical management traffic to local devices, the target is often a hostname that resolves to a private address (nas.local, router.lan, mDNS names, internal DNS names, etc.). In those cases this PR still sends the request through the proxy, which is exactly the path this change is trying to avoid.

Please either:

  • resolve hostnames before the CIDR check, or
  • document and test that only literal IP targets are supported here.

I’d strongly prefer the former, plus a regression test covering a hostname that resolves to RFC1918 space.

Copy link
Copy Markdown
Collaborator

@luispater luispater left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary:
The overall direction makes sense, but there is still one correctness gap before this is safe to merge.

Key findings:

  • Blocking: isNoProxyHost() only recognizes literal IPs. Requests to http://localhost:... still go through the configured proxy because net.ParseIP("localhost") returns nil, and bracketed IPv6 loopback URLs such as http://[::1]/... are missed as well. That means a common local-provider setup is still broken even though the PR claims to bypass loopback/LAN targets.
  • Non-blocking: there is no regression coverage for the new bypass logic, so this kind of hole is easy to miss in CI.

Test plan:

  • go test ./...

Follow-up:

  • Please normalize the host with URL hostname semantics (or explicitly special-case localhost) and add regression coverage for localhost, literal private IPv4, and IPv6 loopback.

@thebtf thebtf force-pushed the fix/no-proxy-private-ips branch from 76210ef to 2c226dd Compare April 10, 2026 09:52
thebtf added a commit to thebtf/CLIProxyAPIPlus that referenced this pull request Apr 10, 2026
Management UI "Pick Models" requests to LAN targets (e.g. LM Studio
at 172.20.23.32) timed out because apiCallTransport unconditionally
routed every call through the global proxy, and most proxies cannot
reach the local network.

Add isNoProxyHost() which detects RFC 1918 / loopback / link-local
targets and bypasses the proxy for them. The helper accepts:
  - literal IPv4 / IPv6 (with or without port)
  - bracketed IPv6 ("[::1]", "[::1]:443")
  - zone-suffixed IPv6 ("fe80::1%eth0")
  - "localhost" as a universal loopback alias
  - arbitrary hostnames — resolved via the default resolver with a
    500ms timeout; every resolved address must fall inside the
    no-proxy ranges, so a single public address disables the bypass
    and LAN DNS poisoning cannot hijack external traffic
  - unresolvable hostnames — bypass disabled (proxy retains a chance
    to reach the target)

Defaults: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16,
169.254.0.0/16, ::1/128, fc00::/7, fe80::/10. Overridable via
`no-proxy-cidrs` in config.yaml (full replacement, not append).

apiCallTransport now takes an explicit targetHost argument. The
Pick-Models path passes the parsed URL host; callers that do not
know the target yet pass "" and retain the previous always-proxy
behaviour.

Includes regression tests for private/public IPv4, IPv6 loopback,
link-local with zone, bracketed forms, localhost, and LAN-vs.-public
bypass via the transport.

Upstream PR: router-for-me#1960
@thebtf
Copy link
Copy Markdown
Contributor Author

thebtf commented Apr 10, 2026

@luispater @xkonjin thanks for the review — both gaps are now fixed, force-pushed to fix/no-proxy-private-ips.

Fixed

Hostname resolutionisNoProxyHost is no longer restricted to literal IPs:

  • localhost is always treated as loopback (case-insensitive), regardless of DNS, so CI/test environments that do not map it to 127.0.0.1 still work.
  • Non-literal hostnames (nas.lan, printer.local, custom DNS names) are resolved via net.DefaultResolver.LookupIPAddr with a 500ms context timeout, so a missing or slow DNS entry cannot block the management request.
  • Every resolved address must fall inside the configured no-proxy ranges. A single public address disables the bypass, so LAN DNS poisoning cannot hijack external traffic through the direct transport.
  • Resolution failures leave the bypass disabled, so the proxy still has a chance to reach the target.

IPv6 edge cases:

  • Bracketed form [::1] (both with and without port).
  • Zone identifier fe80::1%eth0 trimmed before parsing.
  • ULA (fd00::/8) and link-local (fe80::/10) covered by the defaults.

Also fixed: the initial commit added net.SplitHostPort / ParseIP / ParseCIDR without importing "net" — the management package did not build with the patch applied against the current tree. The import is now present.

Merge with the new API-key proxy resolution

Rebased on top of ada8e290 feat(api): enhance proxy resolution for API key-based auth. The no-proxy check short-circuits before the per-auth / per-API-key / global proxy candidate list, so the new proxyURLFromAPIKeyConfig logic is fully preserved for every non-bypassed call site.

apiCallTransport signature is now (auth, targetHost). Only the APICall handler passes a real host (parsed URL); OAuth token refresh callers pass "" and keep the always-proxy behaviour — loopback cannot leak to those paths.

Regression tests

api_tools_test.go adds:

  • TestIsNoProxyHost — 17 cases: private/public IPv4, IPv4 with port, IPv6 loopback bare/bracketed/with-port, IPv6 link-local with zone, IPv6 ULA, IPv6 public, literal localhost (case-insensitive, with/without port).
  • TestAPICallTransportBypassesProxyForPrivateIP — LAN target reaches a direct transport even when a global proxy is configured.
  • TestAPICallTransportUsesProxyForPublicIPapi.openai.com:443 still routes through the global proxy.

Existing tests for the API-key candidate list continue to pass and call the new two-arg signature with "".

@thebtf thebtf force-pushed the fix/no-proxy-private-ips branch from 2c226dd to 8eb3cb6 Compare April 12, 2026 18:37
thebtf added a commit to thebtf/CLIProxyAPIPlus that referenced this pull request Apr 12, 2026
Management UI "Pick Models" requests to LAN targets (e.g. LM Studio
at 172.20.23.32) timed out because apiCallTransport unconditionally
routed every call through the global proxy, and most proxies cannot
reach the local network.

Add isNoProxyHost() which detects RFC 1918 / loopback / link-local
targets and bypasses the proxy for them. The helper accepts:
  - literal IPv4 / IPv6 (with or without port)
  - bracketed IPv6 ("[::1]", "[::1]:443")
  - zone-suffixed IPv6 ("fe80::1%eth0")
  - "localhost" as a universal loopback alias
  - arbitrary hostnames — resolved via the default resolver with a
    500ms timeout; every resolved address must fall inside the
    no-proxy ranges, so a single public address disables the bypass
    and LAN DNS poisoning cannot hijack external traffic
  - unresolvable hostnames — bypass disabled (proxy retains a chance
    to reach the target)

Defaults: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16,
169.254.0.0/16, ::1/128, fc00::/7, fe80::/10. Overridable via
`no-proxy-cidrs` in config.yaml (full replacement, not append).

apiCallTransport now takes an explicit targetHost argument. The
Pick-Models path passes the parsed URL host; callers that do not
know the target yet pass "" and retain the previous always-proxy
behaviour.

Includes regression tests for private/public IPv4, IPv6 loopback,
link-local with zone, bracketed forms, localhost, and LAN-vs.-public
bypass via the transport.

Upstream PR: router-for-me#1960
Management UI "Pick Models" requests to LAN targets (e.g. LM Studio at
172.20.23.32) timed out because apiCallTransport unconditionally routed
every call through the global proxy, and most proxies cannot reach the
local network.

Add isNoProxyHost() which detects RFC 1918 / loopback / link-local
targets and bypasses the proxy for them. The helper accepts:
  - literal IPv4 / IPv6 (with or without port)
  - bracketed IPv6 ("[::1]", "[::1]:443")
  - zone-suffixed IPv6 ("fe80::1%eth0")
  - "localhost" as a universal loopback alias
  - arbitrary hostnames — resolved via the default resolver with a
    500ms timeout; every resolved address must fall inside the
    no-proxy ranges, so a single public address disables the bypass
    and LAN DNS poisoning cannot hijack external traffic
  - unresolvable hostnames — bypass disabled (proxy retains a chance
    to reach the target)

Defaults: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16,
169.254.0.0/16, ::1/128, fc00::/7, fe80::/10. Overridable via
`no-proxy-cidrs` in config.yaml (full replacement, not append).

apiCallTransport now takes an explicit targetHost argument. The
Pick-Models path passes the parsed URL host; callers that do not
know the target yet (OAuth token refresh to Google endpoints) pass
"" and retain the previous always-proxy behaviour.

Regression tests cover private/public IPv4, IPv6 loopback, link-local
with zone, bracketed forms, localhost variants, and LAN-vs.-public
bypass through the transport.
@thebtf thebtf force-pushed the fix/no-proxy-private-ips branch from 8eb3cb6 to 96780b2 Compare April 17, 2026 02:48
thebtf added a commit to thebtf/CLIProxyAPIPlus that referenced this pull request Apr 17, 2026
Management UI "Pick Models" requests to LAN targets (e.g. LM Studio
at 172.20.23.32) timed out because apiCallTransport unconditionally
routed every call through the global proxy, and most proxies cannot
reach the local network.

Add isNoProxyHost() which detects RFC 1918 / loopback / link-local
targets and bypasses the proxy for them. The helper accepts:
  - literal IPv4 / IPv6 (with or without port)
  - bracketed IPv6 ("[::1]", "[::1]:443")
  - zone-suffixed IPv6 ("fe80::1%eth0")
  - "localhost" as a universal loopback alias
  - arbitrary hostnames — resolved via the default resolver with a
    500ms timeout; every resolved address must fall inside the
    no-proxy ranges, so a single public address disables the bypass
    and LAN DNS poisoning cannot hijack external traffic
  - unresolvable hostnames — bypass disabled (proxy retains a chance
    to reach the target)

Defaults: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16,
169.254.0.0/16, ::1/128, fc00::/7, fe80::/10. Overridable via
`no-proxy-cidrs` in config.yaml (full replacement, not append).

apiCallTransport now takes an explicit targetHost argument. The
Pick-Models path passes the parsed URL host; callers that do not
know the target yet pass "" and retain the previous always-proxy
behaviour.

Includes regression tests for private/public IPv4, IPv6 loopback,
link-local with zone, bracketed forms, localhost, and LAN-vs.-public
bypass via the transport.

Upstream PR: router-for-me#1960
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants