Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,13 @@ When auth misbehaves, the first thing to check is alpaca's own log:
excluded by the host allowlist, or didn't match the proxy's
advertised schemes. The client sees a 502; this line tells you which
proxy and that the chain ran out of options.
- `Suppressing block of proxy "…" (grace period after network change)` —
alpaca detected a network change and is temporarily ignoring proxy
failures to let the network stabilize. This is normal and resolves
within a few seconds. If the proxy remains unreachable after the grace
period, it will be blocked as usual and alpaca falls back to DIRECT.
Adjust the grace period via `ALPACA_GRACE_PERIOD` (seconds, default 0 /
disabled, set to e.g. 3 to enable).

### Platform support for Kerberos

Expand Down Expand Up @@ -283,6 +290,7 @@ can set this manually using the `-C` flag.
| `BASIC_CREDENTIALS` | `login:password` for HTTP Basic proxy auth |
| `ALPACA_PROXY_AUTH_ALLOWLIST` | Comma-separated DNS suffixes that may receive proxy credentials. Applies uniformly to Basic, NTLM, and Negotiate. Default is permissive (any host); set to `*` for the explicit permissive form. See "Restricting where Alpaca sends credentials" above. |
| `NTLM_USERNAME` / `NTLM_DOMAIN` | Used by the keyring credential source (Linux/GNOME, Windows) |
| `ALPACA_GRACE_PERIOD` | Seconds to suppress proxy-blocking after a network change is detected. Prevents a race where concurrent requests re-block the proxy before the network is fully stable. Default: `0` (disabled). Set to e.g. `3` to enable. |

---

Expand Down
43 changes: 42 additions & 1 deletion proxyfinder.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,36 @@ import (
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
)

const contextKeyProxy = contextKey("proxy")

// gracePeriod is the duration after a network change during which blockProxy()
// calls are suppressed. This prevents the race where concurrent requests fail
// (because the network is not yet fully stable) and immediately re-block the
// proxy that was just unblocked by checkForUpdates().
//
// Configurable via ALPACA_GRACE_PERIOD (seconds). Set to 0 to disable.
// Default: 0 (disabled).
var gracePeriod = getGracePeriod()

func getGracePeriod() time.Duration {
if val := os.Getenv("ALPACA_GRACE_PERIOD"); val != "" {
if secs, err := strconv.Atoi(val); err == nil {
if secs <= 0 {
return 0
}
return time.Duration(secs) * time.Second
}
}
return 0
}

func getProxyFromContext(req *http.Request) (*url.URL, error) {
if value := req.Context().Value(contextKeyProxy); value != nil {
proxy := value.(*url.URL)
Expand All @@ -41,11 +65,17 @@ type ProxyFinder struct {
wrapper *PACWrapper
blocked *blocklist
enableSocks bool
// graceUntil suppresses blockProxy() calls for a short period after a
// network change is detected. This prevents the race where a concurrent
// request fails (because the network is not yet fully stable) and
// immediately re-blocks the proxy that checkForUpdates() just unblocked.
graceUntil time.Time
now func() time.Time
sync.Mutex
}

func NewProxyFinder(pacurl string, wrapper *PACWrapper, enableSocks bool) *ProxyFinder {
pf := &ProxyFinder{wrapper: wrapper, blocked: newBlocklist(), enableSocks: enableSocks}
pf := &ProxyFinder{wrapper: wrapper, blocked: newBlocklist(), enableSocks: enableSocks, now: time.Now}
pf.runner = new(PACRunner)
pf.fetcher = newPACFetcher(pacurl)
pf.checkForUpdates()
Expand Down Expand Up @@ -76,11 +106,13 @@ func (pf *ProxyFinder) checkForUpdates() {
if pacjs == nil {
if !pf.fetcher.isConnected() {
pf.blocked = newBlocklist()
pf.graceUntil = pf.now().Add(gracePeriod)
pf.wrapper.Wrap(nil)
}
return
}
pf.blocked = newBlocklist()
pf.graceUntil = pf.now().Add(gracePeriod)
if err := pf.runner.Update(pacjs); err != nil {
log.Printf("Error running PAC JS: %q", err)
} else {
Expand Down Expand Up @@ -156,5 +188,14 @@ func (pf *ProxyFinder) findProxyForRequest(req *http.Request) (*url.URL, error)
}

func (pf *ProxyFinder) blockProxy(proxy string) {
if gracePeriod > 0 {
pf.Lock()
inGracePeriod := pf.now().Before(pf.graceUntil)
pf.Unlock()
if inGracePeriod {
log.Printf("Suppressing block of proxy %q (grace period after network change)", proxy)
return
}
}
pf.blocked.add(proxy)
}
45 changes: 45 additions & 0 deletions proxyfinder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -107,3 +108,47 @@ func TestSkipBadProxies(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "primary:80", proxy.Host)
}

func TestBlockProxySuppressedDuringGracePeriod(t *testing.T) {
// Enable grace period for this test (default is 0/disabled).
oldGracePeriod := gracePeriod
gracePeriod = 5 * time.Second
defer func() { gracePeriod = oldGracePeriod }()

js := `function FindProxyForURL(url, host) { return "PROXY proxy.test:80; DIRECT" }`
server := httptest.NewServer(http.HandlerFunc(pacjsHandler(js)))
defer server.Close()
pw := NewPACWrapper(PACData{Port: 1})
pf := NewProxyFinder(server.URL, pw, false)

req := httptest.NewRequest(http.MethodGet, "https://www.test", nil)
ctx := context.WithValue(req.Context(), contextKeyID, 0)
req = req.WithContext(ctx)

// Simulate a network change: checkForUpdates resets the blocklist and sets graceUntil.
// The grace period is already active from NewProxyFinder calling checkForUpdates().
// Verify that blockProxy is suppressed during the grace period.
pf.blockProxy("proxy.test:80")
assert.False(t, pf.blocked.contains("proxy.test:80"),
"blockProxy should be suppressed during grace period")

// Proxy should still be returned (not blocked).
proxy, err := pf.findProxyForRequest(req)
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "proxy.test:80", proxy.Host)

// After grace period expires, blockProxy should work again.
pf.Lock()
pf.graceUntil = pf.now().Add(-1 * time.Second) // expire the grace period
pf.Unlock()

pf.blockProxy("proxy.test:80")
assert.True(t, pf.blocked.contains("proxy.test:80"),
"blockProxy should work after grace period expires")

// Proxy should now be skipped (blocked), falling through to DIRECT.
proxy, err = pf.findProxyForRequest(req)
require.NoError(t, err)
assert.Nil(t, proxy) // DIRECT
}