├── CODEOWNERS ├── .gitignore ├── AUTHORS ├── log_test.go ├── go.mod ├── pacfinder_windows.go ├── contextid.go ├── requestlogger.go ├── keyring.go ├── pacfinder_darwin_test.go ├── blocklist_test.go ├── contextid_test.go ├── pacfinder_unix.go ├── requestlogger_test.go ├── credentials_test.go ├── pacfinder_unix_test.go ├── pacwrapper_test.go ├── transport.go ├── blocklist.go ├── authenticator.go ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── credentials.go ├── transport_test.go ├── pacwrapper.go ├── keyring_darwin.go ├── go.sum ├── keyring_darwin_test.go ├── authenticator_test.go ├── netmonitor.go ├── proxyfinder_test.go ├── pacfinder_darwin.go ├── pacfetcher_test.go ├── proxyfinder.go ├── pacfetcher.go ├── README.md ├── main.go ├── netmonitor_test.go ├── main_test.go ├── proxy.go ├── LICENSE ├── pacrunner.go ├── proxy_test.go └── pacrunner_test.go /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @camh- @juliaogris @marcelocantos @samuong 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /alpaca 2 | /alpaca.exe 3 | .vscode 4 | __debug_bin* 5 | .idea 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Cam Hutchison 2 | Chris Gavin 3 | Dave Goddard 4 | James Moriarty 5 | Julia Ogris 6 | Keilin Olsen 7 | Keith Ferguson 8 | Kyle Cronin 9 | Marcelo Cantos 10 | Sam Uong 11 | Seng Ern Gan 12 | Lucas Santiago 13 | -------------------------------------------------------------------------------- /log_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "io" 19 | "log" 20 | ) 21 | 22 | func init() { 23 | log.SetOutput(io.Discard) 24 | } 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/samuong/alpaca/v2 2 | 3 | go 1.22.3 4 | 5 | toolchain go1.22.4 6 | 7 | require ( 8 | github.com/gobwas/glob v0.2.3 9 | github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 10 | github.com/robertkrimen/otto v0.4.0 11 | github.com/samuong/go-ntlmssp v0.0.0-20240616070040-65a20607c744 12 | github.com/stretchr/testify v1.9.0 13 | github.com/zalando/go-keyring v0.2.5 14 | golang.org/x/term v0.21.0 15 | ) 16 | 17 | require ( 18 | github.com/alessio/shellescape v1.4.1 // indirect 19 | github.com/danieljoos/wincred v1.2.0 // indirect 20 | github.com/davecgh/go-spew v1.1.1 // indirect 21 | github.com/godbus/dbus/v5 v5.1.0 // indirect 22 | github.com/pmezard/go-difflib v1.0.0 // indirect 23 | golang.org/x/crypto v0.24.0 // indirect 24 | golang.org/x/sys v0.21.0 // indirect 25 | golang.org/x/text v0.16.0 // indirect 26 | gopkg.in/sourcemap.v1 v1.0.5 // indirect 27 | gopkg.in/yaml.v3 v3.0.1 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /pacfinder_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | type pacFinder struct{ 18 | pacURL string 19 | } 20 | 21 | func newPacFinder(pacURL string) *pacFinder { 22 | return &pacFinder{pacURL: pacURL} 23 | } 24 | 25 | func (finder *pacFinder) findPACURL() (string, error) { 26 | return finder.pacURL, nil 27 | } 28 | 29 | func (finder *pacFinder) pacChanged() bool { 30 | return false 31 | } 32 | -------------------------------------------------------------------------------- /contextid.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "net/http" 20 | "sync/atomic" 21 | ) 22 | 23 | type contextKey string 24 | 25 | const contextKeyID = contextKey("id") 26 | 27 | // AddContextID wraps a http.Handler to add a strictly increasing uint to the 28 | // context of the http.Request with the key "id" as it passes through the 29 | // request to the next handler. 30 | func AddContextID(next http.Handler) http.Handler { 31 | var id uint64 32 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 33 | ctx := context.WithValue( 34 | req.Context(), contextKeyID, atomic.AddUint64(&id, 1), 35 | ) 36 | next.ServeHTTP(w, req.WithContext(ctx)) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /requestlogger.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "log" 19 | "net/http" 20 | ) 21 | 22 | type statusWriter struct { 23 | http.ResponseWriter 24 | status int 25 | } 26 | 27 | func (w *statusWriter) WriteHeader(status int) { 28 | w.status = status 29 | w.ResponseWriter.WriteHeader(status) 30 | } 31 | 32 | func RequestLogger(next http.Handler) http.Handler { 33 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 34 | sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} 35 | next.ServeHTTP(sw, req) 36 | log.Printf( 37 | "[%v] %d %s %s", 38 | req.Context().Value(contextKeyID), 39 | sw.status, 40 | req.Method, 41 | req.URL, 42 | ) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /keyring.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build !darwin 16 | 17 | package main 18 | 19 | import ( 20 | "fmt" 21 | "os" 22 | 23 | "github.com/samuong/go-ntlmssp" 24 | ring "github.com/zalando/go-keyring" 25 | ) 26 | 27 | type keyring struct{} 28 | 29 | func fromKeyring() *keyring { 30 | return &keyring{} 31 | } 32 | 33 | func (k *keyring) getCredentials() (*authenticator, error) { 34 | 35 | var ( 36 | username = os.Getenv("NTLM_USERNAME") 37 | domain = os.Getenv("NTLM_DOMAIN") 38 | ) 39 | 40 | pwd, err := ring.Get("alpaca", username) 41 | if err != nil { 42 | return nil, fmt.Errorf("cannot get user secret from keyring: %w", err) 43 | } 44 | hash := ntlmssp.GetNtlmHash(pwd) 45 | return &authenticator{domain, username, hash}, nil 46 | } 47 | -------------------------------------------------------------------------------- /pacfinder_darwin_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bytes" 19 | "os/exec" 20 | "testing" 21 | 22 | "github.com/stretchr/testify/require" 23 | ) 24 | 25 | func TestFindPACURLStatic(t *testing.T) { 26 | pac := "http://internal.example.com/proxy.pac" 27 | finder := newPacFinder(pac) 28 | 29 | foundPac, _ := finder.findPACURL() 30 | require.Equal(t, pac, foundPac) 31 | } 32 | 33 | func TestFallbackToDefaultWhenNoPACUrl(t *testing.T) { 34 | // arrange 35 | cmdStr := "scutil --proxy | awk '/ProxyAutoConfigURLString/ {printf $3}'" 36 | cmd := exec.Command("bash", "-c", cmdStr) 37 | var out bytes.Buffer 38 | cmd.Stdout = &out 39 | if err := cmd.Run(); err != nil { 40 | t.Fatal(err) 41 | } 42 | 43 | finder := newPacFinder("") 44 | 45 | // act 46 | foundPac, _ := finder.findPACURL() 47 | 48 | // assert 49 | require.Equal(t, out.String(), foundPac) 50 | } 51 | -------------------------------------------------------------------------------- /blocklist_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "testing" 19 | "time" 20 | 21 | "github.com/stretchr/testify/assert" 22 | ) 23 | 24 | func TestBlocklistExpiry(t *testing.T) { 25 | b := newBlocklist() 26 | var now time.Time 27 | b.now = func() time.Time { return now } 28 | 29 | b.add("foo") 30 | assert.True(t, b.contains("foo")) 31 | assert.False(t, b.contains("bar")) 32 | 33 | now = now.Add(3 * time.Minute) 34 | b.add("bar") 35 | assert.True(t, b.contains("foo")) 36 | assert.True(t, b.contains("bar")) 37 | 38 | now = now.Add(3 * time.Minute) 39 | assert.False(t, b.contains("foo")) 40 | assert.True(t, b.contains("bar")) 41 | 42 | now = now.Add(3 * time.Minute) 43 | assert.False(t, b.contains("foo")) 44 | assert.False(t, b.contains("bar")) 45 | } 46 | 47 | func TestBlocklistDuplicateEntry(t *testing.T) { 48 | b := newBlocklist() 49 | var now time.Time 50 | b.now = func() time.Time { return now } 51 | b.add("foo") 52 | now = now.Add(3*time.Minute) 53 | b.add("foo") 54 | now = now.Add(3*time.Minute) 55 | b.contains("foo") 56 | now = now.Add(3*time.Minute) 57 | b.contains("foo") 58 | } 59 | -------------------------------------------------------------------------------- /contextid_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "io" 19 | "net/http" 20 | "net/http/httptest" 21 | "strconv" 22 | "testing" 23 | 24 | "github.com/stretchr/testify/assert" 25 | "github.com/stretchr/testify/require" 26 | ) 27 | 28 | func getIDFromRequest(t *testing.T, server *httptest.Server) uint { 29 | res, err := http.Get(server.URL) 30 | require.NoError(t, err) 31 | b, err := io.ReadAll(res.Body) 32 | require.NoError(t, err) 33 | id, err := strconv.ParseUint(string(b), 10, 64) 34 | require.NoError(t, err) 35 | return uint(id) 36 | } 37 | 38 | func TestContextID(t *testing.T) { 39 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 | id, ok := r.Context().Value(contextKeyID).(uint64) 41 | assert.True(t, ok, "Unexpected type for context id value") 42 | _, err := w.Write([]byte(strconv.FormatUint(uint64(id), 10))) 43 | require.NoError(t, err) 44 | }) 45 | server := httptest.NewServer(AddContextID(handler)) 46 | defer server.Close() 47 | assert.Equal(t, uint(1), getIDFromRequest(t, server)) 48 | assert.Equal(t, uint(2), getIDFromRequest(t, server)) 49 | } 50 | -------------------------------------------------------------------------------- /pacfinder_unix.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build aix || dragonfly || freebsd || linux || netbsd || openbsd || solaris 16 | // +build aix dragonfly freebsd linux netbsd openbsd solaris 17 | 18 | package main 19 | 20 | import ( 21 | "os/exec" 22 | "strings" 23 | ) 24 | 25 | type pacFinder struct { 26 | pacUrl string 27 | auto bool 28 | } 29 | 30 | func newPacFinder(pacUrl string) *pacFinder { 31 | return &pacFinder{pacUrl, pacUrl == ""} 32 | } 33 | 34 | func (finder *pacFinder) findPACURL() (string, error) { 35 | if !finder.auto { 36 | return finder.pacUrl, nil 37 | } 38 | 39 | // Hopefully Linux, FreeBSD, Solaris, etc. will have GNOME 3 installed... 40 | // TODO: Figure out how to do this for KDE. 41 | cmd := exec.Command("gsettings", "get", "org.gnome.system.proxy", "autoconfig-url") 42 | out, err := cmd.Output() 43 | if err != nil { 44 | return "", err 45 | } 46 | return strings.Trim(string(out), "'\n"), nil 47 | } 48 | 49 | func (finder *pacFinder) pacChanged() bool { 50 | if url, _ := finder.findPACURL(); finder.pacUrl != url { 51 | finder.pacUrl = url 52 | return true 53 | } 54 | 55 | return false 56 | } 57 | -------------------------------------------------------------------------------- /requestlogger_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bytes" 19 | "log" 20 | "net/http" 21 | "net/http/httptest" 22 | "os" 23 | "testing" 24 | 25 | "github.com/stretchr/testify/assert" 26 | "github.com/stretchr/testify/require" 27 | ) 28 | 29 | func TestRequestLogger(t *testing.T) { 30 | tests := map[string]struct { 31 | status int 32 | wrapper func(http.Handler) http.Handler 33 | out string 34 | }{ 35 | "No Status": {0, nil, "[] 200 GET /"}, 36 | "Given Status": {http.StatusNotFound, nil, "[] 404 GET /"}, 37 | "Context": {http.StatusOK, AddContextID, "[1] 200 GET /"}, 38 | } 39 | for name, tt := range tests { 40 | t.Run(name, func(t *testing.T) { 41 | b := &bytes.Buffer{} 42 | log.SetOutput(b) 43 | hfunc := func(w http.ResponseWriter, req *http.Request) { 44 | if tt.status != 0 { 45 | w.WriteHeader(tt.status) 46 | } 47 | } 48 | var handler http.Handler = http.HandlerFunc(hfunc) 49 | handler = RequestLogger(handler) 50 | if tt.wrapper != nil { 51 | handler = tt.wrapper(handler) 52 | } 53 | server := httptest.NewServer(handler) 54 | defer server.Close() 55 | _, err := http.Get(server.URL) 56 | require.NoError(t, err) 57 | log.SetOutput(os.Stderr) 58 | assert.Contains(t, b.String(), tt.out) 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /credentials_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bytes" 19 | "testing" 20 | 21 | "github.com/samuong/go-ntlmssp" 22 | "github.com/stretchr/testify/assert" 23 | "github.com/stretchr/testify/require" 24 | ) 25 | 26 | func TestTerminal(t *testing.T) { 27 | fakeTerm := &terminal{ 28 | readPassword: func() ([]byte, error) { return []byte("guest"), nil }, 29 | stdout: new(bytes.Buffer), 30 | } 31 | a, err := fakeTerm.forUser("isis", "malory").getCredentials() 32 | require.NoError(t, err) 33 | assert.Equal(t, "malory@isis:823893adfad2cda6e1a414f3ebdf58f7", a.String()) 34 | } 35 | 36 | func TestEnvVar(t *testing.T) { 37 | a, err := fromEnvVar("malory@isis:823893adfad2cda6e1a414f3ebdf58f7").getCredentials() 38 | require.NoError(t, err) 39 | assert.Equal(t, "isis", a.domain) 40 | assert.Equal(t, "malory", a.username) 41 | assert.Equal(t, ntlmssp.GetNtlmHash("guest"), a.hash) 42 | } 43 | 44 | func TestEnvVarInvalid(t *testing.T) { 45 | for _, test := range []struct { 46 | name string 47 | input string 48 | }{ 49 | {name: "NoAtDomain", input: "malory:823893adfad2cda6e1a414f3ebdf58f7"}, 50 | {name: "NoColonHash", input: "malory@isis"}, 51 | {name: "WrongOrder", input: "823893adfad2cda6e1a414f3ebdf58f7:malory@isis"}, 52 | } { 53 | t.Run(test.name, func(t *testing.T) { 54 | _, err := fromEnvVar(test.input).getCredentials() 55 | assert.Error(t, err) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pacfinder_unix_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build aix || dragonfly || freebsd || linux || netbsd || openbsd || solaris 16 | // +build aix dragonfly freebsd linux netbsd openbsd solaris 17 | 18 | package main 19 | 20 | import ( 21 | "os" 22 | "path/filepath" 23 | "testing" 24 | 25 | "github.com/stretchr/testify/assert" 26 | "github.com/stretchr/testify/require" 27 | ) 28 | 29 | func TestFindPACURL(t *testing.T) { 30 | dir, err := os.MkdirTemp("", "alpaca") 31 | require.NoError(t, err) 32 | defer os.RemoveAll(dir) 33 | oldpath := os.Getenv("PATH") 34 | defer require.NoError(t, os.Setenv("PATH", oldpath)) 35 | 36 | require.NoError(t, os.Setenv("PATH", dir)) 37 | tmpfn := filepath.Join(dir, "gsettings") 38 | mockcmd := "#!/bin/sh\necho \\'http://internal.example.com/proxy.pac\\'\n" 39 | require.NoError(t, os.WriteFile(tmpfn, []byte(mockcmd), 0700)) 40 | 41 | pf := newPacFinder("") 42 | pacURL, err := pf.findPACURL() 43 | require.NoError(t, err) 44 | assert.Equal(t, "http://internal.example.com/proxy.pac", pacURL) 45 | } 46 | 47 | func TestFindPACURLWhenGsettingsIsntAvailable(t *testing.T) { 48 | dir, err := os.MkdirTemp("", "alpaca") 49 | require.NoError(t, err) 50 | defer os.RemoveAll(dir) 51 | oldpath := os.Getenv("PATH") 52 | defer require.NoError(t, os.Setenv("PATH", oldpath)) 53 | require.NoError(t, os.Setenv("PATH", dir)) 54 | pf := newPacFinder("") 55 | _, err = pf.findPACURL() 56 | require.NotNil(t, err) 57 | } 58 | -------------------------------------------------------------------------------- /pacwrapper_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "io" 19 | "net/http" 20 | "net/http/httptest" 21 | "testing" 22 | 23 | "github.com/stretchr/testify/assert" 24 | "github.com/stretchr/testify/require" 25 | ) 26 | 27 | func TestWrapPAC(t *testing.T) { 28 | pw := NewPACWrapper(PACData{Port: 1234}) 29 | pac := `function FindProxyForURL(url, host) { return "DIRECT" }` 30 | pw.Wrap([]byte(pac)) 31 | assert.Contains(t, pw.alpacaPAC, pac) 32 | assert.Contains(t, pw.alpacaPAC, `"DIRECT" : "PROXY localhost:1234"`) 33 | } 34 | 35 | func TestWrapEmptyPAC(t *testing.T) { 36 | pw := NewPACWrapper(PACData{Port: 1234}) 37 | pw.Wrap(nil) 38 | assert.Contains(t, pw.alpacaPAC, `return "DIRECT"`) 39 | } 40 | 41 | func TestPACServe(t *testing.T) { 42 | pw := NewPACWrapper(PACData{Port: 1234}) 43 | pac := `function FindProxyForURL(url, host) { return "DIRECT" }` 44 | pw.Wrap([]byte(pac)) 45 | mux := http.NewServeMux() 46 | pw.SetupHandlers(mux) 47 | server := httptest.NewServer(mux) 48 | defer server.Close() 49 | 50 | resp, err := http.Get(server.URL + "/alpaca.pac") 51 | require.NoError(t, err) 52 | 53 | assert.Equal(t, resp.StatusCode, http.StatusOK) 54 | assert.Equal(t, "application/x-ns-proxy-autoconfig", resp.Header.Get("Content-Type")) 55 | b, err := io.ReadAll(resp.Body) 56 | body := string(b) 57 | require.NoError(t, err) 58 | assert.Contains(t, body, pac) 59 | assert.Contains(t, body, `"DIRECT" : "PROXY localhost:1234"`) 60 | resp.Body.Close() 61 | } 62 | -------------------------------------------------------------------------------- /transport.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bufio" 19 | "crypto/tls" 20 | "errors" 21 | "net" 22 | "net/http" 23 | "net/url" 24 | ) 25 | 26 | // transport creates and manages the lifetime of a net.Conn. Between the time that a remote server 27 | // is dialled, and the connection hijacked or closed, a client can send HTTP requests using the 28 | // RoundTrip method (rather than writing requests and reading responses on the net.Conn). 29 | type transport struct { 30 | conn net.Conn 31 | reader *bufio.Reader 32 | } 33 | 34 | func (t *transport) dial(proxy *url.URL) error { 35 | if err := t.Close(); err != nil { 36 | return err 37 | } 38 | var conn net.Conn 39 | var err error 40 | if proxy.Scheme == "https" { 41 | conn, err = tls.Dial("tcp", proxy.Host, tlsClientConfig) 42 | } else { 43 | conn, err = net.Dial("tcp", proxy.Host) 44 | } 45 | if err != nil { 46 | return &net.OpError{Op: "proxyconnect", Net: "tcp", Err: err} 47 | } 48 | t.conn = conn 49 | t.reader = bufio.NewReader(conn) 50 | return nil 51 | } 52 | 53 | func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { 54 | if t.conn == nil { 55 | return nil, errors.New("no connection, can't send request") 56 | } 57 | if err := req.Write(t.conn); err != nil { 58 | return nil, err 59 | } 60 | return http.ReadResponse(t.reader, req) 61 | } 62 | 63 | func (t *transport) hijack() net.Conn { 64 | defer func() { 65 | t.conn = nil 66 | t.reader = nil 67 | }() 68 | return t.conn 69 | } 70 | 71 | func (t *transport) Close() error { 72 | if t.conn == nil { 73 | return nil 74 | } 75 | defer func() { 76 | t.conn = nil 77 | t.reader = nil 78 | }() 79 | return t.conn.Close() 80 | } 81 | -------------------------------------------------------------------------------- /blocklist.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "sync" 20 | "time" 21 | ) 22 | 23 | // This duration was chosen to match Chrome's behaviour (see "Evaluating proxy lists" in 24 | // https://crsrc.org/net/docs/proxy.md). 25 | const maxAge = 5 * time.Minute 26 | 27 | type blocklist struct { 28 | entries []string // Slice of entries, ordered by expiry time 29 | expiry map[string]time.Time // Map containing the expiry time for each entry 30 | now func() time.Time 31 | mux sync.Mutex 32 | } 33 | 34 | func newBlocklist() *blocklist { 35 | return &blocklist{ 36 | entries: []string{}, 37 | expiry: map[string]time.Time{}, 38 | now: time.Now, 39 | } 40 | } 41 | 42 | func (b *blocklist) add(entry string) { 43 | b.mux.Lock() 44 | defer b.mux.Unlock() 45 | b.sweep() 46 | if _, ok := b.expiry[entry]; ok { 47 | // Ignore duplicate entries. An entry can only have a single expiry time, so it 48 | // shouldn't be added to the `entries` slice twice. Otherwise we'll have problems 49 | // deleting it later. 50 | return 51 | } 52 | b.expiry[entry] = b.now().Add(maxAge) 53 | b.entries = append(b.entries, entry) 54 | } 55 | 56 | func (b *blocklist) contains(entry string) bool { 57 | b.mux.Lock() 58 | defer b.mux.Unlock() 59 | b.sweep() 60 | _, ok := b.expiry[entry] 61 | return ok 62 | } 63 | 64 | func (b *blocklist) sweep() { 65 | // Delete any stale entries from both the slice and the map. This function is *not* 66 | // reentrant; `mux` should be locked before calling this function! 67 | count := 0 68 | for _, entry := range b.entries { 69 | expiry, ok := b.expiry[entry] 70 | if !ok { 71 | // This should never happen. 72 | panic(fmt.Sprintf("blocklist contains entry %q without expiry time", entry)) 73 | } 74 | if b.now().Before(expiry) { 75 | break 76 | } 77 | delete(b.expiry, entry) 78 | count++ 79 | } 80 | b.entries = b.entries[count:] 81 | } 82 | -------------------------------------------------------------------------------- /authenticator.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2024 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "encoding/base64" 19 | "encoding/hex" 20 | "fmt" 21 | "log" 22 | "net/http" 23 | "os" 24 | "strings" 25 | 26 | "github.com/samuong/go-ntlmssp" 27 | ) 28 | 29 | type authenticator struct { 30 | domain string 31 | username string 32 | hash []byte 33 | } 34 | 35 | func (a authenticator) do(req *http.Request, rt http.RoundTripper) (*http.Response, error) { 36 | hostname, _ := os.Hostname() // in case of error, just use the zero value ("") as hostname 37 | negotiate, err := ntlmssp.NewNegotiateMessage(a.domain, hostname) 38 | if err != nil { 39 | log.Printf("Error creating NTLM Type 1 (Negotiate) message: %v", err) 40 | return nil, err 41 | } 42 | req.Header.Set("Proxy-Authorization", "NTLM "+base64.StdEncoding.EncodeToString(negotiate)) 43 | resp, err := rt.RoundTrip(req) 44 | if err != nil { 45 | log.Printf("Error sending NTLM Type 1 (Negotiate) request: %v", err) 46 | return nil, err 47 | } else if resp.StatusCode != http.StatusProxyAuthRequired { 48 | log.Printf("Expected response with status 407, got %s", resp.Status) 49 | return resp, nil 50 | } 51 | resp.Body.Close() 52 | challenge, err := base64.StdEncoding.DecodeString( 53 | strings.TrimPrefix(resp.Header.Get("Proxy-Authenticate"), "NTLM ")) 54 | if err != nil { 55 | log.Printf("Error decoding NTLM Type 2 (Challenge) message: %v", err) 56 | return nil, err 57 | } 58 | authenticate, err := ntlmssp.ProcessChallengeWithHash( 59 | challenge, a.domain, a.username, a.hash) 60 | if err != nil { 61 | log.Printf("Error processing NTLM Type 2 (Challenge) message: %v", err) 62 | return nil, err 63 | } 64 | req.Header.Set("Proxy-Authorization", 65 | "NTLM "+base64.StdEncoding.EncodeToString(authenticate)) 66 | return rt.RoundTrip(req) 67 | } 68 | 69 | func (a authenticator) String() string { 70 | return fmt.Sprintf("%s@%s:%s", a.username, a.domain, hex.EncodeToString(a.hash)) 71 | } 72 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: '**' 6 | pull_request: 7 | - master 8 | tags-ignore: '**' 9 | 10 | jobs: 11 | format: 12 | strategy: 13 | matrix: 14 | os: [ 'ubuntu-latest' ] 15 | go: [ '1.22' ] 16 | 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ matrix.go }} 25 | 26 | - run: gofmt -l $(find . -type f -name '*.go') 2>&1 27 | 28 | lint: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v2 32 | - name: golangci-lint 33 | uses: golangci/golangci-lint-action@v2 34 | 35 | test: 36 | strategy: 37 | matrix: 38 | os: [ 'macos-13', 'ubuntu-22.04', 'ubuntu-22.04-arm', 'windows-2022' ] 39 | go: [ '1.22' ] 40 | 41 | runs-on: ${{ matrix.os }} 42 | 43 | steps: 44 | - uses: actions/checkout@v2 45 | 46 | - uses: actions/setup-go@v5 47 | with: 48 | go-version: ${{ matrix.go }} 49 | 50 | - run: go test ./... 51 | env: 52 | CGO_ENABLED: 1 53 | 54 | build: 55 | strategy: 56 | fail-fast: false 57 | matrix: 58 | target: 59 | - os: 'macos-13' 60 | goos: 'darwin' 61 | goarch: 'amd64' 62 | - os: 'macos-13' 63 | goos: 'darwin' 64 | goarch: 'arm64' 65 | - os: 'ubuntu-22.04' 66 | goos: 'linux' 67 | goarch: 'amd64' 68 | - os: 'ubuntu-22.04-arm' 69 | goos: 'linux' 70 | goarch: 'arm64' 71 | - os: 'windows-2022' 72 | goos: 'windows' 73 | goarch: 'amd64' 74 | go: [ '1.22' ] 75 | 76 | runs-on: ${{ matrix.target.os }} 77 | 78 | steps: 79 | - uses: actions/checkout@v2 80 | 81 | - uses: actions/setup-go@v5 82 | with: 83 | go-version: ${{ matrix.go }} 84 | 85 | - if: matrix.target.goos == 'darwin' && matrix.target.goarch == 'arm64' 86 | run: | 87 | echo SDKROOT=$(xcrun --sdk macosx --show-sdk-path) >> $GITHUB_ENV 88 | 89 | - run: | 90 | go build -v . 91 | env: 92 | GOOS: ${{ matrix.target.goos }} 93 | GOARCH: ${{ matrix.target.goarch }} 94 | CGO_ENABLED: 1 95 | 96 | - uses: actions/upload-artifact@v4 97 | with: 98 | name: alpaca-${{ matrix.target.goos }}-${{ matrix.target.goarch }} 99 | path: alpaca 100 | -------------------------------------------------------------------------------- /credentials.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance from the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "encoding/hex" 19 | "errors" 20 | "fmt" 21 | "io" 22 | "log" 23 | "os" 24 | "strings" 25 | 26 | "github.com/samuong/go-ntlmssp" 27 | "golang.org/x/term" 28 | ) 29 | 30 | type credentialSource interface { 31 | getCredentials() (*authenticator, error) 32 | } 33 | 34 | type terminal struct { 35 | readPassword func() ([]byte, error) 36 | stdout io.Writer 37 | domain, username string 38 | } 39 | 40 | func fromTerminal() *terminal { 41 | return &terminal{ 42 | readPassword: func() ([]byte, error) { 43 | return term.ReadPassword(int(os.Stdin.Fd())) 44 | }, 45 | stdout: os.Stdout, 46 | } 47 | } 48 | 49 | func (t *terminal) forUser(domain, username string) *terminal { 50 | t.domain = domain 51 | t.username = username 52 | return t 53 | } 54 | 55 | func (t *terminal) getCredentials() (*authenticator, error) { 56 | fmt.Fprintf(t.stdout, "Password (for %s\\%s): ", t.domain, t.username) 57 | buf, err := t.readPassword() 58 | fmt.Println() 59 | if err != nil { 60 | return nil, fmt.Errorf("error reading password from stdin: %w", err) 61 | } 62 | return &authenticator{ 63 | domain: t.domain, 64 | username: t.username, 65 | hash: ntlmssp.GetNtlmHash(string(buf)), 66 | }, nil 67 | } 68 | 69 | type envVar struct { 70 | value string 71 | } 72 | 73 | func fromEnvVar(value string) *envVar { 74 | return &envVar{value: value} 75 | } 76 | 77 | func (e *envVar) getCredentials() (*authenticator, error) { 78 | at := strings.IndexRune(e.value, '@') 79 | colon := strings.IndexRune(e.value, ':') 80 | if at == -1 || colon == -1 || at > colon { 81 | return nil, errors.New("invalid credentials string, please run `alpaca -H`") 82 | } 83 | domain := e.value[at+1 : colon] 84 | username := e.value[0:at] 85 | hash, err := hex.DecodeString(e.value[colon+1:]) 86 | if err != nil { 87 | return nil, fmt.Errorf("invalid hash, please run `alpaca -H`: %w", err) 88 | } 89 | log.Printf("Found credentials for %s\\%s in environment", domain, username) 90 | return &authenticator{domain, username, hash}, nil 91 | } 92 | -------------------------------------------------------------------------------- /transport_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021, 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bufio" 19 | "io" 20 | "net/http" 21 | "net/http/httptest" 22 | "net/url" 23 | "testing" 24 | 25 | "github.com/stretchr/testify/assert" 26 | "github.com/stretchr/testify/require" 27 | ) 28 | 29 | func TestTransport(t *testing.T) { 30 | handler := func(w http.ResponseWriter, req *http.Request) { 31 | _, err := w.Write([]byte("It works!")) 32 | require.NoError(t, err) 33 | } 34 | server := httptest.NewServer(http.HandlerFunc(handler)) 35 | defer server.Close() 36 | var tr transport 37 | require.NoError(t, tr.dial(&url.URL{Host: server.Listener.Addr().String()})) 38 | defer tr.Close() 39 | req, err := http.NewRequest(http.MethodGet, server.URL, nil) 40 | require.NoError(t, err) 41 | 42 | t.Run("RoundTrip", func(t *testing.T) { 43 | resp, err := tr.RoundTrip(req) 44 | require.NoError(t, err) 45 | defer resp.Body.Close() 46 | assert.Equal(t, http.StatusOK, resp.StatusCode) 47 | body, err := io.ReadAll(resp.Body) 48 | require.NoError(t, err) 49 | assert.Equal(t, "It works!", string(body)) 50 | }) 51 | 52 | t.Run("Hijack", func(t *testing.T) { 53 | conn := tr.hijack() 54 | defer conn.Close() 55 | require.NoError(t, req.Write(conn)) 56 | resp, err := http.ReadResponse(bufio.NewReader(conn), req) 57 | require.NoError(t, err) 58 | defer resp.Body.Close() 59 | assert.Equal(t, http.StatusOK, resp.StatusCode) 60 | body, err := io.ReadAll(resp.Body) 61 | require.NoError(t, err) 62 | assert.Equal(t, "It works!", string(body)) 63 | assert.NoError(t, tr.Close()) 64 | }) 65 | } 66 | 67 | func TestTransportErrors(t *testing.T) { 68 | var tr transport 69 | req, err := http.NewRequest(http.MethodGet, "http://alpaca.test", nil) 70 | require.NoError(t, err) 71 | 72 | t.Run("NotConnected", func (t *testing.T) { 73 | _, err = tr.RoundTrip(req) 74 | assert.Error(t, err) 75 | }) 76 | 77 | t.Run("Closed", func (t *testing.T) { 78 | require.NoError(t, tr.Close()) 79 | _, err = tr.RoundTrip(req) 80 | assert.Error(t, err) 81 | }) 82 | 83 | t.Run("CloseTwice", func (t *testing.T) { 84 | assert.NoError(t, tr.Close()) 85 | assert.NoError(t, tr.Close()) 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /pacwrapper.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bytes" 19 | "log" 20 | "net/http" 21 | "text/template" 22 | ) 23 | 24 | // PACData contains program configuration to be made available to the pacWrapTmpl. 25 | type PACData struct { 26 | Port int 27 | } 28 | 29 | type pacData struct { 30 | PACData 31 | UpstreamPAC string 32 | } 33 | 34 | type PACWrapper struct { 35 | data pacData 36 | tmpl *template.Template 37 | alpacaPAC string 38 | } 39 | 40 | // PACWrapper template for serving a PAC file to point at alpaca or DIRECT. If we have a valid 41 | // PAC file, we wrap that PAC file with a wrapper function that only returns "DIRECT" or 42 | // "localhost:port". If we do not have a PAC file, the PAC function we serve only returns "DIRECT", 43 | // which should prevent all requests reaching us. 44 | var pacWrapTmpl = `// Wrapped for and by alpaca 45 | function FindProxyForURL(url, host) { 46 | {{ if .UpstreamPAC }} 47 | return FindProxyForURL(url, host) === "DIRECT" ? "DIRECT" : "PROXY localhost:{{.Port}}"; 48 | {{.UpstreamPAC}} 49 | {{ else }} 50 | return "DIRECT"; 51 | {{ end }} 52 | } 53 | ` 54 | 55 | func NewPACWrapper(data PACData) *PACWrapper { 56 | t := template.Must(template.New("alpaca").Parse(pacWrapTmpl)) 57 | return &PACWrapper{pacData{data, ""}, t, ""} 58 | } 59 | 60 | func (pw *PACWrapper) Wrap(pacjs []byte) { 61 | pac := string(pacjs) 62 | if pac == pw.data.UpstreamPAC && pw.alpacaPAC != "" { 63 | return 64 | } 65 | pw.data.UpstreamPAC = pac 66 | b := &bytes.Buffer{} 67 | if err := pw.tmpl.Execute(b, pw.data); err != nil { 68 | log.Printf("error executing PAC wrap template: %v", err) 69 | return 70 | } 71 | pw.alpacaPAC = b.String() 72 | } 73 | 74 | func (pw *PACWrapper) SetupHandlers(mux *http.ServeMux) { 75 | mux.HandleFunc("/alpaca.pac", pw.handlePAC) 76 | } 77 | 78 | func (pw *PACWrapper) handlePAC(w http.ResponseWriter, req *http.Request) { 79 | if req.Method != http.MethodGet { 80 | w.WriteHeader(http.StatusMethodNotAllowed) 81 | return 82 | } 83 | w.Header().Set("Content-Type", "application/x-ns-proxy-autoconfig") 84 | if _, err := w.Write([]byte(pw.alpacaPAC)); err != nil { 85 | log.Printf("Error writing PAC to response: %v", err) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /keyring_darwin.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2020, 2021 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "log" 21 | "os/exec" 22 | "strings" 23 | 24 | "github.com/keybase/go-keychain" 25 | "github.com/samuong/go-ntlmssp" 26 | ) 27 | 28 | type keyring struct { 29 | execCommand func(name string, arg ...string) *exec.Cmd 30 | } 31 | 32 | func fromKeyring() *keyring { 33 | return &keyring{execCommand: exec.Command} 34 | } 35 | 36 | func (k *keyring) readDefaultForNoMAD(key string) (string, error) { 37 | userDomain := "com.trusourcelabs.NoMAD" 38 | mpDomain := fmt.Sprintf("/Library/Managed Preferences/%s.plist", userDomain) 39 | 40 | // Read from managed preferences first 41 | out, err := k.execCommand("defaults", "read", mpDomain, key).Output() 42 | if err != nil { 43 | // Read from user preferences if not in managed preferences 44 | out, err = k.execCommand("defaults", "read", userDomain, key).Output() 45 | } 46 | if err != nil { 47 | return "", err 48 | } 49 | return strings.TrimSpace(string(out)), nil 50 | } 51 | 52 | func (k *keyring) readPasswordFromKeychain(userPrincipal string) string { 53 | // https://nomad.menu/help/keychain-usage/ 54 | query := keychain.NewItem() 55 | query.SetSecClass(keychain.SecClassGenericPassword) 56 | query.SetAccount(userPrincipal) 57 | query.SetReturnAttributes(true) 58 | query.SetReturnData(true) 59 | results, err := keychain.QueryItem(query) 60 | if err != nil || len(results) != 1 || results[0].Label != "NoMAD" { 61 | return "" 62 | } 63 | return string(results[0].Data) 64 | } 65 | 66 | func (k *keyring) getCredentials() (*authenticator, error) { 67 | useKeychain, err := k.readDefaultForNoMAD("UseKeychain") 68 | if err != nil { 69 | return nil, err 70 | } else if useKeychain != "1" { 71 | return nil, errors.New("NoMAD found, but not configured to use keychain") 72 | } 73 | userPrincipal, err := k.readDefaultForNoMAD("UserPrincipal") 74 | if err != nil { 75 | return nil, err 76 | } 77 | substrs := strings.Split(userPrincipal, "@") 78 | if len(substrs) != 2 { 79 | return nil, errors.New("Couldn't retrieve AD domain and username from NoMAD.") 80 | } 81 | user, domain := substrs[0], substrs[1] 82 | hash := ntlmssp.GetNtlmHash(k.readPasswordFromKeychain(userPrincipal)) 83 | log.Printf("Found NoMAD credentials for %s\\%s in system keychain", domain, user) 84 | return &authenticator{domain, user, hash}, nil 85 | } 86 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | 13 | outputs: 14 | version: ${{ steps.get_version.outputs.version }} 15 | upload_url: ${{ steps.create_release.outputs.upload_url }} 16 | 17 | steps: 18 | - uses: actions/create-release@v1 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 21 | with: 22 | tag_name: ${{ github.ref }} 23 | release_name: Release ${{ github.ref }} 24 | draft: false 25 | prerelease: false 26 | id: create_release 27 | 28 | - id: get_version 29 | run: echo ::set-output name=version::${GITHUB_REF/refs\/tags\//} 30 | 31 | build: 32 | strategy: 33 | matrix: 34 | target: 35 | - os: 'macos-13' 36 | goos: 'darwin' 37 | goarch: 'amd64' 38 | - os: 'macos-13' 39 | goos: 'darwin' 40 | goarch: 'arm64' 41 | - os: 'ubuntu-22.04' 42 | goos: 'linux' 43 | goarch: 'amd64' 44 | - os: 'ubuntu-22.04-arm' 45 | goos: 'linux' 46 | goarch: 'arm64' 47 | - os: 'windows-2022' 48 | goos: 'windows' 49 | goarch: 'amd64' 50 | ext: '.exe' 51 | go: [ '1.22' ] 52 | 53 | runs-on: ${{ matrix.target.os }} 54 | 55 | needs: [ release ] 56 | 57 | steps: 58 | - uses: actions/checkout@v2 59 | 60 | - uses: actions/setup-go@v5 61 | with: 62 | go-version: ${{ matrix.go }} 63 | 64 | - if: matrix.target.goos == 'darwin' && matrix.target.goarch == 'arm64' 65 | run: | 66 | echo SDKROOT=$(xcrun --sdk macosx --show-sdk-path) >> $GITHUB_ENV 67 | 68 | - run: go build -v -o ${{ matrix.target.goos }}-${{ matrix.target.goarch }} -ldflags="-X 'main.BuildVersion=${{ needs.release.outputs.version }}'" . 69 | env: 70 | GOOS: ${{ matrix.target.goos }} 71 | GOARCH: ${{ matrix.target.goarch }} 72 | CGO_ENABLED: 1 73 | 74 | - uses: actions/upload-release-asset@v1.0.1 75 | env: 76 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 77 | with: 78 | upload_url: ${{ needs.release.outputs.upload_url}} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 79 | asset_path: ./${{ matrix.target.goos }}-${{ matrix.target.goarch }} 80 | asset_name: alpaca_${{ needs.release.outputs.version }}_${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ matrix.target.ext }} 81 | asset_content_type: application/zip 82 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= 2 | github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= 3 | github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= 4 | github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 8 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 9 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 10 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 11 | github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= 12 | github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/robertkrimen/otto v0.4.0 h1:/c0GRrK1XDPcgIasAsnlpBT5DelIeB9U/Z/JCQsgr7E= 16 | github.com/robertkrimen/otto v0.4.0/go.mod h1:uW9yN1CYflmUQYvAMS0m+ZiNo3dMzRUDQJX0jWbzgxw= 17 | github.com/samuong/go-ntlmssp v0.0.0-20240616070040-65a20607c744 h1:AD1UeK7fZRLY7TEeQQZNTuHX3RAspwLUC36mNi47Xcs= 18 | github.com/samuong/go-ntlmssp v0.0.0-20240616070040-65a20607c744/go.mod h1:ioghl8+axI3Mx5Cs1LU/LzW18JE71qbwXwpOv/F9lCc= 19 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 20 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 21 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 22 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 23 | github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= 24 | github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= 25 | golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= 26 | golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= 27 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 28 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 29 | golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= 30 | golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= 31 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 32 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 35 | gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= 36 | gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= 37 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 38 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 39 | -------------------------------------------------------------------------------- /keyring_darwin_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | "os/exec" 21 | "testing" 22 | 23 | "github.com/stretchr/testify/require" 24 | ) 25 | 26 | func fakeExecCommand(env []string) func(string, ...string) *exec.Cmd { 27 | return func(name string, arg ...string) *exec.Cmd { 28 | arg = append([]string{"-test.run=TestMockDefaults", "--", name}, arg...) 29 | cmd := exec.Command(os.Args[0], arg...) 30 | cmd.Env = append(env, "ALPACA_WANT_MOCK_DEFAULTS=1") 31 | return cmd 32 | } 33 | } 34 | 35 | func TestMockDefaults(t *testing.T) { 36 | if os.Getenv("ALPACA_WANT_MOCK_DEFAULTS") != "1" { 37 | return 38 | } 39 | args := os.Args 40 | for i := 0; i < len(args); i++ { 41 | if args[i] == "--" { 42 | args = args[i+1:] 43 | break 44 | } 45 | } 46 | if len(args) == len(os.Args) { 47 | fmt.Println("no command") 48 | os.Exit(2) 49 | } else if cmd := args[0]; cmd != "defaults" { 50 | fmt.Printf("%s: command not found\n", cmd) 51 | os.Exit(127) 52 | } else if len(args) != 4 || args[1] != "read" { 53 | fmt.Println("usage: defaults read ") 54 | os.Exit(255) 55 | } 56 | domain, key := args[2], args[3] 57 | if os.Getenv("DOMAIN_EXISTS") != "1" { 58 | fmt.Printf("Domain %s does not exist\n", domain) 59 | os.Exit(1) 60 | } else if key == "UseKeychain" && os.Getenv("USE_KEYCHAIN") == "1" { 61 | fmt.Println(1) 62 | os.Exit(0) 63 | } else if key == "UserPrincipal" { 64 | switch os.Getenv("USER_PRINCIPAL") { 65 | case "1": 66 | fmt.Println("malory") 67 | os.Exit(0) 68 | case "2": 69 | fmt.Println("malory@ISIS") 70 | os.Exit(0) 71 | } 72 | } 73 | fmt.Printf("The domain/default pair of (%s, %s) does not exist\n", domain, key) 74 | os.Exit(1) 75 | } 76 | 77 | func TestNoMADNotConfigured(t *testing.T) { 78 | env := []string{"DOMAIN_EXISTS=0"} 79 | k := &keyring{execCommand: fakeExecCommand(env)} 80 | _, err := k.getCredentials() 81 | require.Error(t, err) 82 | } 83 | 84 | func TestNoMADNotUsingKeychain(t *testing.T) { 85 | env := []string{"DOMAIN_EXISTS=1", "USE_KEYCHAIN=0"} 86 | k := &keyring{execCommand: fakeExecCommand(env)} 87 | _, err := k.getCredentials() 88 | require.Error(t, err) 89 | } 90 | 91 | func TestNoMADNoUserPrincipal(t *testing.T) { 92 | env := []string{"DOMAIN_EXISTS=1", "USE_KEYCHAIN=1", "USER_PRINCIPAL=0"} 93 | k := &keyring{execCommand: fakeExecCommand(env)} 94 | _, err := k.getCredentials() 95 | require.Error(t, err) 96 | } 97 | 98 | func TestNoMADInvalidUserPrincipal(t *testing.T) { 99 | env := []string{"DOMAIN_EXISTS=1", "USE_KEYCHAIN=1", "USER_PRINCIPAL=1"} 100 | k := &keyring{execCommand: fakeExecCommand(env)} 101 | _, err := k.getCredentials() 102 | require.Error(t, err) 103 | } 104 | -------------------------------------------------------------------------------- /authenticator_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bytes" 19 | "encoding/base64" 20 | "encoding/binary" 21 | "fmt" 22 | "io" 23 | "net/http" 24 | "net/http/httptest" 25 | "net/url" 26 | "strings" 27 | "testing" 28 | 29 | "github.com/samuong/go-ntlmssp" 30 | "github.com/stretchr/testify/assert" 31 | "github.com/stretchr/testify/require" 32 | ) 33 | 34 | type ntlmServer struct { 35 | t *testing.T 36 | } 37 | 38 | func (s ntlmServer) ServeHTTP(w http.ResponseWriter, req *http.Request) { 39 | hdr := req.Header.Get("Proxy-Authorization") 40 | if !strings.HasPrefix(hdr, "NTLM ") { 41 | sendProxyAuthRequired(w) 42 | return 43 | } 44 | msg, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(hdr, "NTLM ")) 45 | require.NoError(s.t, err) 46 | require.True(s.t, bytes.Equal(msg[0:8], []byte("NTLMSSP\x00")), "Missing NTLMSSP signature") 47 | msgType := binary.LittleEndian.Uint32(msg[8:12]) 48 | switch msgType { 49 | case 1: 50 | sendChallengeResponse(w) 51 | case 3: 52 | req.Header.Del("Proxy-Authenticate") 53 | _, err := w.Write([]byte("Access granted")) 54 | require.NoError(s.t, err) 55 | default: 56 | s.t.Fatalf("Unexpected NTLM message type: %x", msgType) 57 | } 58 | } 59 | 60 | func sendProxyAuthRequired(w http.ResponseWriter) { 61 | w.Header().Set("Proxy-Authenticate", "NTLM") 62 | w.Header().Set("Connection", "close") 63 | w.WriteHeader(http.StatusProxyAuthRequired) 64 | fmt.Fprintf(w, "oh noes!") 65 | } 66 | 67 | func sendChallengeResponse(w http.ResponseWriter) { 68 | w.Header().Set("Proxy-Authenticate", "NTLM TlRMTVNTUAACAAAADAAMADgAAAAFgomi+Rp9UDbAycMAAAAAAAAAAKIAogBEAAAABgEAAAAAAA9HAEwATwBCAEEATAACAAwARwBMAE8AQgBBAEwAAQAeAFAAWABZAEEAVQAwADAAMgBNAEUATAAwADEAMAAzAAQAHABnAGwAbwBiAGEAbAAuAGEAbgB6AC4AYwBvAG0AAwA8AHAAeAB5AGEAdQAwADAAMgBtAGUAbAAwADEAMAAzAC4AZwBsAG8AYgBhAGwALgBhAG4AegAuAGMAbwBtAAcACABQ7ZOkOQbVAQAAAAA=") 69 | w.WriteHeader(http.StatusProxyAuthRequired) 70 | } 71 | 72 | func TestNtlmAuth(t *testing.T) { 73 | server := httptest.NewServer(ntlmServer{t}) 74 | defer server.Close() 75 | serverAddr := server.Listener.Addr().String() 76 | tr := &http.Transport{Proxy: http.ProxyURL(&url.URL{Host: serverAddr})} 77 | req, err := http.NewRequest(http.MethodGet, "http://"+serverAddr, nil) 78 | require.NoError(t, err) 79 | resp, err := tr.RoundTrip(req) 80 | require.NoError(t, err) 81 | require.NoError(t, resp.Body.Close()) 82 | require.Equal(t, http.StatusProxyAuthRequired, resp.StatusCode) 83 | auth := &authenticator{"isis", "malory", ntlmssp.GetNtlmHash("guest")} 84 | resp, err = auth.do(req, tr) 85 | require.NoError(t, err) 86 | defer resp.Body.Close() 87 | assert.Equal(t, http.StatusOK, resp.StatusCode) 88 | body, err := io.ReadAll(resp.Body) 89 | require.NoError(t, err) 90 | assert.Equal(t, "Access granted", string(body)) 91 | } 92 | -------------------------------------------------------------------------------- /netmonitor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2024 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "log" 19 | "net" 20 | "slices" 21 | ) 22 | 23 | type netMonitor interface { 24 | addrsChanged() bool 25 | } 26 | 27 | type netMonitorImpl struct { 28 | addrs map[string]struct{} 29 | routes []net.IP 30 | getAddrs func() ([]net.Addr, error) 31 | dial func(network, addr string) (net.Conn, error) 32 | } 33 | 34 | func newNetMonitor() *netMonitorImpl { 35 | return &netMonitorImpl{getAddrs: net.InterfaceAddrs, dial: net.Dial} 36 | } 37 | 38 | func (nm *netMonitorImpl) addrsChanged() bool { 39 | addrs, err := nm.getAddrs() 40 | if err != nil { 41 | log.Printf("Error while getting network interface addresses: %q", err) 42 | return false 43 | } 44 | set := addrSliceToSet(addrs) 45 | // Probe for routes to a set of remote addresses. These addresses are 46 | // the same as those used by myIpAddressEx. 47 | // TODO: Cache the results so they don't need to be recalculated in 48 | // myIpAddress (and myIpAddressEx, when implemented). 49 | remotes := []string{ 50 | "8.8.8.8", "2001:4860:4860::8888", // public addresses 51 | "10.0.0.0", "172.16.0.0", "192.168.0.0", "FC00::", // private addresses 52 | } 53 | locals := make([]net.IP, len(remotes)) 54 | for i, remote := range remotes { 55 | locals[i] = nm.probeRoute(remote, false) 56 | } 57 | if setsAreEqual(set, nm.addrs) && slices.EqualFunc(locals, nm.routes, net.IP.Equal) { 58 | return false 59 | } 60 | nm.addrs = set 61 | nm.routes = locals 62 | return true 63 | } 64 | 65 | func addrSliceToSet(slice []net.Addr) map[string]struct{} { 66 | set := make(map[string]struct{}) 67 | for _, addr := range slice { 68 | set[addr.String()] = struct{}{} 69 | } 70 | return set 71 | } 72 | 73 | func setsAreEqual(a, b map[string]struct{}) bool { 74 | if len(a) != len(b) { 75 | return false 76 | } 77 | for k := range a { 78 | if _, ok := b[k]; !ok { 79 | return false 80 | } 81 | } 82 | return true 83 | } 84 | 85 | // probeRoute creates a UDP "connection" to the remote address, and returns the 86 | // local interface address. This does involve a system call, but does not 87 | // generate any network traffic since UDP is a connectionless protocol. 88 | func (nm *netMonitorImpl) probeRoute(host string, ipv4only bool) net.IP { 89 | var network string 90 | if ipv4only { 91 | network = "udp4" 92 | } else { 93 | network = "udp" 94 | } 95 | conn, err := nm.dial(network, net.JoinHostPort(host, "80")) 96 | if err != nil { 97 | return nil 98 | } 99 | defer conn.Close() 100 | local, ok := conn.LocalAddr().(*net.UDPAddr) 101 | if !ok { 102 | // Since we called dial with network set to "udp4" or "udp", we 103 | // expect this to be a *net.UDPAddr. If this fails, it's a bug 104 | // in Alpaca, and hopefully users will report it. But it's not 105 | // worth panicking over so we won't end the request here. 106 | log.Printf("unexpected: probeRoute host=%q ipv4only=%t: %v", host, ipv4only, err) 107 | return nil 108 | } 109 | if ip := local.IP; ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { 110 | return nil 111 | } 112 | return local.IP 113 | } 114 | -------------------------------------------------------------------------------- /proxyfinder_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "net/http" 21 | "net/http/httptest" 22 | "testing" 23 | 24 | "github.com/stretchr/testify/assert" 25 | "github.com/stretchr/testify/require" 26 | ) 27 | 28 | func TestFindProxyForRequest(t *testing.T) { 29 | tests := []struct { 30 | name string 31 | body string 32 | expectError bool 33 | expected string 34 | }{ 35 | {"JavaScriptError", "throw 'error'", true, ""}, 36 | {"MultipleBlocks", "return 'PROXY proxy.test:1; DIRECT'", false, "proxy.test:1"}, 37 | {"Direct", "return 'DIRECT'", false, ""}, 38 | {"Proxy", "return 'PROXY proxy.test:2'", false, "proxy.test:2"}, 39 | {"ProxyWithoutPort", "return 'PROXY proxy.test'", false, "proxy.test:80"}, 40 | {"Socks", "return 'SOCKS socksproxy.test:3'", true, "socksproxy.test:3"}, 41 | {"Http", "return 'HTTP http.test:4'", false, "http.test:4"}, 42 | {"HttpWithoutPort", "return 'HTTP http.test'", false, "http.test:80"}, 43 | {"Https", "return 'HTTPS https.test:5'", false, "https.test:5"}, 44 | {"HttpsWithoutPort", "return 'HTTPS https.test'", false, "https.test:443"}, 45 | {"InvalidReturnValue", "return 'INVALID RETURN VALUE'", true, ""}, 46 | } 47 | for i, test := range tests { 48 | t.Run(test.name, func(t *testing.T) { 49 | js := fmt.Sprintf("function FindProxyForURL(url, host) { %s }", test.body) 50 | server := httptest.NewServer(http.HandlerFunc(pacjsHandler(js))) 51 | defer server.Close() 52 | pw := NewPACWrapper(PACData{Port: 1}) 53 | pf := NewProxyFinder(server.URL, pw) 54 | req := httptest.NewRequest(http.MethodGet, "https://www.test", nil) 55 | ctx := context.WithValue(req.Context(), contextKeyID, i) 56 | req = req.WithContext(ctx) 57 | proxy, err := pf.findProxyForRequest(req) 58 | if test.expectError { 59 | assert.NotNil(t, err) 60 | return 61 | } 62 | require.NoError(t, err) 63 | if test.expected == "" { 64 | assert.Nil(t, proxy) 65 | return 66 | } 67 | require.NotNil(t, proxy) 68 | assert.Equal(t, test.expected, proxy.Host) 69 | }) 70 | } 71 | } 72 | 73 | func TestFallbackToDirectWhenNotConnected(t *testing.T) { 74 | url := "http://pacserver.invalid/nonexistent.pac" 75 | pw := NewPACWrapper(PACData{Port: 1}) 76 | pf := NewProxyFinder(url, pw) 77 | req := httptest.NewRequest(http.MethodGet, "http://www.test", nil) 78 | proxy, err := pf.findProxyForRequest(req) 79 | require.NoError(t, err) 80 | assert.Nil(t, proxy) 81 | } 82 | 83 | // Removed TestFallbackToDirectWhenNoPACURL - behaviour is fallback to system default when no PACURL, test case TestFallbackToDefaultWhenNoPACUrl 84 | 85 | func TestSkipBadProxies(t *testing.T) { 86 | js := `function FindProxyForURL(url, host) { return "PROXY primary:80; PROXY backup:80" }` 87 | server := httptest.NewServer(http.HandlerFunc(pacjsHandler(js))) 88 | defer server.Close() 89 | pw := NewPACWrapper(PACData{Port: 1}) 90 | pf := NewProxyFinder(server.URL, pw) 91 | req := httptest.NewRequest(http.MethodGet, "https://www.test", nil) 92 | ctx := context.WithValue(req.Context(), contextKeyID, 0) 93 | req = req.WithContext(ctx) 94 | proxy, err := pf.findProxyForRequest(req) 95 | require.NoError(t, err) 96 | assert.Equal(t, "primary:80", proxy.Host) 97 | pf.blocked.add("primary:80") 98 | proxy, err = pf.findProxyForRequest(req) 99 | require.NoError(t, err) 100 | assert.Equal(t, "backup:80", proxy.Host) 101 | pf.blocked.add("backup:80") 102 | proxy, err = pf.findProxyForRequest(req) 103 | require.NoError(t, err) 104 | assert.Equal(t, "primary:80", proxy.Host) 105 | } 106 | -------------------------------------------------------------------------------- /pacfinder_darwin.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2022, 2025 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | package main 15 | 16 | /* 17 | #cgo LDFLAGS: -framework CoreFoundation -framework SystemConfiguration 18 | #include 19 | #include 20 | 21 | static SCDynamicStoreRef SCDynamicStoreCreate_trampoline() { 22 | return SCDynamicStoreCreate(kCFAllocatorDefault, CFSTR("alpaca"), NULL, NULL); 23 | } 24 | 25 | typedef const CFStringRef CFStringRef_Const; 26 | */ 27 | import "C" 28 | import ( 29 | "log" 30 | "unsafe" 31 | ) 32 | 33 | type pacFinder struct { 34 | pacUrl string 35 | storeRef C.SCDynamicStoreRef 36 | } 37 | 38 | func newPacFinder(pacUrl string) *pacFinder { 39 | if pacUrl != "" { 40 | return &pacFinder{pacUrl, 0} 41 | } 42 | storeRef := C.SCDynamicStoreCreate_trampoline() 43 | if storeRef == 0 { 44 | log.Print("Unable to access system network information") 45 | return &pacFinder{"", 0} 46 | } 47 | return &pacFinder{"", storeRef} 48 | } 49 | 50 | func (finder *pacFinder) pacChanged() bool { 51 | if url, _ := finder.findPACURL(); finder.pacUrl != url { 52 | finder.pacUrl = url 53 | return true 54 | } 55 | return false 56 | } 57 | 58 | func (finder *pacFinder) findPACURL() (string, error) { 59 | if finder.storeRef == 0 { 60 | return finder.pacUrl, nil 61 | } 62 | 63 | proxySettings := C.SCDynamicStoreCopyProxies(finder.storeRef) 64 | if proxySettings == 0 { 65 | // log.Printf("No proxy settings found using SCDynamicStoreCopyProxies") 66 | return "", nil 67 | } 68 | defer C.CFRelease(C.CFTypeRef(proxySettings)) 69 | 70 | kSCPropNetProxiesProxyAutoConfigEnable := CFStringCreateWithCString("ProxyAutoConfigEnable") 71 | kSCPropNetProxiesProxyAutoConfigURLString := CFStringCreateWithCString("ProxyAutoConfigURLString") 72 | 73 | pacEnabled := C.CFNumberRef(C.CFDictionaryGetValue(proxySettings, unsafe.Pointer(kSCPropNetProxiesProxyAutoConfigEnable))) 74 | if pacEnabled == 0 { 75 | // log.Printf("PAC enable flag not found in proxy settings using SCDynamicStoreCopyProxies") 76 | return "", nil 77 | } 78 | 79 | var enabled C.int 80 | if C.CFNumberGetValue(pacEnabled, C.kCFNumberIntType, unsafe.Pointer(&enabled)) == 0 { 81 | // log.Printf("Could not retrieve value of PAC enabled flag using SCDynamicStoreCopyProxies") 82 | return "", nil 83 | } 84 | 85 | if enabled == 0 { 86 | // log.Printf("PAC is not enabled using SCDynamicStoreCopyProxies") 87 | return "", nil 88 | } 89 | 90 | pacURL := C.CFStringRef(C.CFDictionaryGetValue(proxySettings, unsafe.Pointer(kSCPropNetProxiesProxyAutoConfigURLString))) 91 | if pacURL == 0 { 92 | // log.Printf("PAC URL not found in proxy settings using SCDynamicStoreCopyProxies") 93 | return "", nil 94 | } 95 | 96 | return CFStringToString(pacURL), nil 97 | } 98 | 99 | // CFStringToString converts a CFStringRef to a string. 100 | func CFStringToString(s C.CFStringRef) string { 101 | p := C.CFStringGetCStringPtr(s, C.kCFStringEncodingUTF8) 102 | if p != nil { 103 | return C.GoString(p) 104 | } 105 | 106 | length := C.CFStringGetLength(s) 107 | if length == 0 { 108 | return "" 109 | } 110 | 111 | maxBufLen := C.CFStringGetMaximumSizeForEncoding(length, C.kCFStringEncodingUTF8) 112 | if maxBufLen == 0 { 113 | return "" 114 | } 115 | 116 | buf := make([]byte, maxBufLen) 117 | var usedBufLen C.CFIndex 118 | _ = C.CFStringGetBytes(s, C.CFRange{0, length}, C.kCFStringEncodingUTF8, 0, C.Boolean(0), (*C.UInt8)(&buf[0]), maxBufLen, &usedBufLen) 119 | return string(buf[:usedBufLen]) 120 | } 121 | 122 | // Helper function to create a CFStringRef from a Go string 123 | func CFStringCreateWithCString(s string) C.CFStringRef { 124 | cs := C.CString(s) 125 | defer C.free(unsafe.Pointer(cs)) 126 | 127 | return C.CFStringCreateWithCString(C.kCFAllocatorDefault, cs, C.kCFStringEncodingUTF8) 128 | } 129 | -------------------------------------------------------------------------------- /pacfetcher_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "net/http" 19 | "net/http/httptest" 20 | "net/url" 21 | "os" 22 | "path" 23 | "path/filepath" 24 | "strings" 25 | "testing" 26 | 27 | "github.com/stretchr/testify/assert" 28 | "github.com/stretchr/testify/require" 29 | ) 30 | 31 | func init() { 32 | // Set the retry delay to zero, so that it doesn't delay unit tests. 33 | delayAfterFailedDownload = 0 34 | } 35 | 36 | func pacjsHandler(pacjs string) http.HandlerFunc { 37 | return func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(pacjs)) } 38 | } 39 | 40 | type pacServerWhichFailsOnFirstTry struct { 41 | t *testing.T 42 | count int 43 | } 44 | 45 | func (s *pacServerWhichFailsOnFirstTry) ServeHTTP(w http.ResponseWriter, req *http.Request) { 46 | s.count++ 47 | if s.count == 1 { 48 | w.WriteHeader(http.StatusInternalServerError) 49 | return 50 | } 51 | _, err := w.Write([]byte("test script")) 52 | require.NoError(s.t, err) 53 | } 54 | 55 | type fakeNetMonitor struct { 56 | changed bool 57 | } 58 | 59 | func (nm *fakeNetMonitor) addrsChanged() bool { 60 | tmp := nm.changed 61 | nm.changed = false 62 | return tmp 63 | } 64 | 65 | func TestDownload(t *testing.T) { 66 | server := httptest.NewServer(http.HandlerFunc(pacjsHandler("test script"))) 67 | defer server.Close() 68 | pf := newPACFetcher(server.URL) 69 | assert.Equal(t, []byte("test script"), pf.download()) 70 | assert.True(t, pf.isConnected()) 71 | } 72 | 73 | func TestDownloadFailsOnFirstTry(t *testing.T) { 74 | s := &pacServerWhichFailsOnFirstTry{t: t, count: 0} 75 | server := httptest.NewServer(s) 76 | defer server.Close() 77 | pf := newPACFetcher(server.URL) 78 | require.Equal(t, 0, s.count) 79 | assert.Equal(t, []byte("test script"), pf.download()) 80 | require.Equal(t, 2, s.count) 81 | assert.True(t, pf.isConnected()) 82 | } 83 | 84 | func TestDownloadWithNetworkChanges(t *testing.T) { 85 | // Initially, the download succeeds and we are connected (to the PAC server). 86 | s1 := httptest.NewServer(http.HandlerFunc(pacjsHandler("test script 1"))) 87 | nm := &fakeNetMonitor{true} 88 | pf := newPACFetcher(s1.URL) 89 | pf.monitor = nm 90 | assert.Equal(t, []byte("test script 1"), pf.download()) 91 | assert.True(t, pf.isConnected()) 92 | // Try again. Nothing changed, so we don't get a new script, but are still connected. 93 | assert.Nil(t, pf.download()) 94 | assert.True(t, pf.isConnected()) 95 | // Disconnect from the network. 96 | s1.Close() 97 | nm.changed = true 98 | assert.Nil(t, pf.download()) 99 | assert.False(t, pf.isConnected()) 100 | // Connect to a new network. 101 | s2 := httptest.NewServer(http.HandlerFunc(pacjsHandler("test script 2"))) 102 | defer s2.Close() 103 | nm.changed = true 104 | pf.pacFinder = newPacFinder(s2.URL) 105 | assert.Equal(t, []byte("test script 2"), pf.download()) 106 | assert.True(t, pf.isConnected()) 107 | } 108 | 109 | func TestResponseLimit(t *testing.T) { 110 | bigscript := strings.Repeat("x", 2*1024*1024) // 2 MB 111 | server := httptest.NewServer(http.HandlerFunc(pacjsHandler(bigscript))) 112 | defer server.Close() 113 | pf := newPACFetcher(server.URL) 114 | assert.Nil(t, pf.download()) 115 | assert.False(t, pf.isConnected()) 116 | } 117 | 118 | func TestPacFromFilesystem(t *testing.T) { 119 | // Set up a test PAC file 120 | content := []byte(`function FindProxyForURL(url, host) { return "DIRECT" }`) 121 | tempdir, err := os.MkdirTemp("", "alpaca") 122 | require.NoError(t, err) 123 | defer os.RemoveAll(tempdir) 124 | pacPath := path.Join(tempdir, "test.pac") 125 | require.NoError(t, os.WriteFile(pacPath, content, 0644)) 126 | pacURL := &url.URL{Scheme: "file", Path: filepath.ToSlash(pacPath)} 127 | pf := newPACFetcher(pacURL.String()) 128 | pf.monitor = newNetMonitor() 129 | assert.Equal(t, content, pf.download()) 130 | assert.True(t, pf.isConnected()) 131 | } 132 | -------------------------------------------------------------------------------- /proxyfinder.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "log" 21 | "net" 22 | "net/http" 23 | "net/url" 24 | "strings" 25 | "sync" 26 | ) 27 | 28 | const contextKeyProxy = contextKey("proxy") 29 | 30 | func getProxyFromContext(req *http.Request) (*url.URL, error) { 31 | if value := req.Context().Value(contextKeyProxy); value != nil { 32 | proxy := value.(*url.URL) 33 | return proxy, nil 34 | } 35 | return nil, nil 36 | } 37 | 38 | type ProxyFinder struct { 39 | runner *PACRunner 40 | fetcher *pacFetcher 41 | wrapper *PACWrapper 42 | blocked *blocklist 43 | sync.Mutex 44 | } 45 | 46 | func NewProxyFinder(pacurl string, wrapper *PACWrapper) *ProxyFinder { 47 | pf := &ProxyFinder{wrapper: wrapper, blocked: newBlocklist()} 48 | pf.runner = new(PACRunner) 49 | pf.fetcher = newPACFetcher(pacurl) 50 | pf.checkForUpdates() 51 | return pf 52 | } 53 | 54 | func (pf *ProxyFinder) WrapHandler(next http.Handler) http.Handler { 55 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 56 | pf.checkForUpdates() 57 | proxy, err := pf.findProxyForRequest(req) 58 | if err != nil { 59 | log.Printf("[%d] %v", req.Context().Value(contextKeyID), err) 60 | w.WriteHeader(http.StatusInternalServerError) 61 | return 62 | } 63 | if proxy != nil { 64 | ctx := context.WithValue(req.Context(), contextKeyProxy, proxy) 65 | req = req.WithContext(ctx) 66 | } 67 | next.ServeHTTP(w, req) 68 | }) 69 | } 70 | 71 | func (pf *ProxyFinder) checkForUpdates() { 72 | pf.Lock() 73 | defer pf.Unlock() 74 | pacjs := pf.fetcher.download() 75 | if pacjs == nil { 76 | if !pf.fetcher.isConnected() { 77 | pf.blocked = newBlocklist() 78 | pf.wrapper.Wrap(nil) 79 | } 80 | return 81 | } 82 | pf.blocked = newBlocklist() 83 | if err := pf.runner.Update(pacjs); err != nil { 84 | log.Printf("Error running PAC JS: %q", err) 85 | } else { 86 | pf.wrapper.Wrap(pacjs) 87 | } 88 | } 89 | 90 | func (pf *ProxyFinder) findProxyForRequest(req *http.Request) (*url.URL, error) { 91 | id := req.Context().Value(contextKeyID) 92 | if pf.fetcher == nil { 93 | log.Printf(`[%d] %s %s via "DIRECT"`, id, req.Method, req.URL) 94 | return nil, nil 95 | } 96 | if !pf.fetcher.isConnected() { 97 | log.Printf(`[%d] %s %s via "DIRECT" (not connected to PAC server)`, 98 | id, req.Method, req.URL) 99 | return nil, nil 100 | } 101 | str, err := pf.runner.FindProxyForURL(*req.URL) 102 | if err != nil { 103 | return nil, err 104 | } 105 | var fallback *url.URL 106 | for _, elem := range strings.Split(str, ";") { 107 | fields := strings.Fields(strings.TrimSpace(elem)) 108 | var scheme string 109 | var defaultPort string 110 | if len(fields) == 0 { 111 | continue 112 | } else if fields[0] == "DIRECT" { 113 | log.Printf("[%d] %s %s via %q", id, req.Method, req.URL, elem) 114 | return nil, nil 115 | } else if fields[0] == "PROXY" || fields[0] == "HTTP" { 116 | scheme = "http" 117 | defaultPort = "80" 118 | } else if fields[0] == "HTTPS" { 119 | scheme = "https" 120 | defaultPort = "443" 121 | } else { 122 | log.Printf("[%d] Couldn't parse proxy: %q", id, elem) 123 | continue 124 | } 125 | proxy := &url.URL{Scheme: scheme, Host: fields[1]} 126 | if proxy.Port() == "" { 127 | proxy.Host = net.JoinHostPort(proxy.Host, defaultPort) 128 | } 129 | if pf.blocked.contains(proxy.Host) { 130 | if fallback == nil { 131 | fallback = proxy 132 | } 133 | continue 134 | } 135 | log.Printf("[%d] %s %s via %q", id, req.Method, req.URL, elem) 136 | return proxy, nil 137 | } 138 | if fallback != nil { 139 | // All the proxies are currently blocked. In this case, we'll temporarily ignore the 140 | // blocklist and fall back to the first proxy that we saw (and skipped). 141 | return fallback, nil 142 | } 143 | return nil, errors.New("no proxies available") 144 | } 145 | 146 | func (pf *ProxyFinder) blockProxy(proxy string) { 147 | pf.blocked.add(proxy) 148 | } 149 | -------------------------------------------------------------------------------- /pacfetcher.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2022, 2025 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bytes" 19 | "fmt" 20 | "io" 21 | "log" 22 | "net/http" 23 | "runtime" 24 | "strings" 25 | "time" 26 | ) 27 | 28 | // The maximum size (in bytes) allowed for a PAC script. At 1 MB, this matches the limit in Chrome. 29 | const maxResponseBytes = 1 * 1024 * 1024 30 | 31 | // The time to wait before retrying a failed PAC download. This is similar to Chrome's delay: 32 | // https://cs.chromium.org/chromium/src/net/proxy_resolution/proxy_resolution_service.cc?l=96&rcl=3db5f65968c3ecab3932c1ff7367ad28834f9502 33 | var delayAfterFailedDownload = 2 * time.Second 34 | 35 | type pacFetcher struct { 36 | pacFinder *pacFinder 37 | monitor netMonitor 38 | client *http.Client 39 | connected bool 40 | //cache []byte 41 | //modified time.Time 42 | //fetched time.Time 43 | //expiry time.Time 44 | //etag string 45 | } 46 | 47 | func newPACFetcher(pacurl string) *pacFetcher { 48 | client := &http.Client{Timeout: 30 * time.Second} 49 | if strings.HasPrefix(pacurl, "file:") { 50 | log.Print("Warning: When using a local PAC file, the online/offline status can't ", 51 | "be determined by the fact that the PAC file is downloaded. Make sure you ", 52 | "check for proxy connectivity in your PAC file!") 53 | if runtime.GOOS == "windows" { 54 | client.Transport = http.NewFileTransport(http.Dir("C:")) 55 | } else { 56 | client.Transport = http.NewFileTransport(http.Dir("/")) 57 | } 58 | } else { 59 | // The DefaultClient in net/http uses the proxy specified in the http(s)_proxy 60 | // environment variable, which could be pointing at this instance of alpaca. When 61 | // fetching the PAC file, we always use a client that goes directly to the server, 62 | // rather than via a proxy. 63 | client.Transport = &http.Transport{Proxy: nil} 64 | } 65 | return &pacFetcher{ 66 | pacFinder: newPacFinder(pacurl), 67 | monitor: newNetMonitor(), 68 | client: client, 69 | } 70 | } 71 | 72 | func requireOK(resp *http.Response, err error) (*http.Response, error) { 73 | if err != nil { 74 | return resp, err 75 | } else if resp.StatusCode != http.StatusOK { 76 | resp.Body.Close() 77 | return nil, fmt.Errorf("expected status 200 OK, got %s", resp.Status) 78 | } else { 79 | return resp, nil 80 | } 81 | } 82 | 83 | func (pf *pacFetcher) download() []byte { 84 | // TODO: Combine pacChanged() and findPACURL() as described in 85 | // https://github.com/samuong/alpaca/pull/156#issuecomment-3125070335 86 | if !pf.monitor.addrsChanged() && !pf.pacFinder.pacChanged() { 87 | return nil 88 | } 89 | pf.connected = false 90 | 91 | pacurl, err := pf.pacFinder.findPACURL() 92 | if err != nil { 93 | log.Printf("Error while trying to detect PAC URL: %v", err) 94 | return nil 95 | } else if pacurl == "" { 96 | log.Println("No PAC URL specified or detected; all requests will be made directly") 97 | return nil 98 | } 99 | 100 | log.Printf("Attempting to download PAC from %s", pacurl) 101 | resp, err := requireOK(pf.client.Get(pacurl)) 102 | if err != nil { 103 | // Sometimes, if we try to download too soon after a network change, the PAC 104 | // download can fail. See https://github.com/samuong/alpaca/issues/8 for details. 105 | log.Printf("Error downloading PAC file, will retry after %v: %q", 106 | delayAfterFailedDownload, err) 107 | time.Sleep(delayAfterFailedDownload) 108 | if resp, err = requireOK(pf.client.Get(pacurl)); err != nil { 109 | log.Printf("Error downloading PAC file, giving up: %q", err) 110 | return nil 111 | } 112 | } 113 | defer resp.Body.Close() 114 | var buf bytes.Buffer 115 | _, err = io.CopyN(&buf, resp.Body, maxResponseBytes) 116 | if err == io.EOF { 117 | pf.connected = true 118 | return buf.Bytes() 119 | } else if err != nil { 120 | log.Printf("Error reading PAC JS from response body: %q", err) 121 | return nil 122 | } else { 123 | log.Printf("PAC JS is too big (limit is %d bytes)", maxResponseBytes) 124 | return nil 125 | } 126 | } 127 | 128 | func (pf *pacFetcher) isConnected() bool { 129 | return pf.connected 130 | } 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alpaca 2 | 3 | ![Latest Tag][2] ![GitHub Workflow Status][3] ![GitHub Releases][4] 4 | 5 | Alpaca is a local HTTP proxy for command-line tools. It supports proxy 6 | auto-configuration (PAC) files and NTLM authentication. 7 | 8 | ## Install using Homebrew 9 | 10 | If you're using macOS and use [Homebrew](https://brew.sh/), you can install 11 | using: 12 | 13 | ```sh 14 | $ brew tap samuong/alpaca 15 | $ brew install samuong/alpaca/alpaca 16 | ``` 17 | 18 | Launch Alpaca by running `alpaca`, or by using `brew services start alpaca`. 19 | 20 | ## Install using Go 21 | 22 | If you've got the [Go](https://golang.org/cmd/go/) tool installed, you can 23 | install using: 24 | 25 | ```sh 26 | $ go install github.com/samuong/alpaca/v2@latest 27 | ``` 28 | 29 | ## Download Binary 30 | 31 | Alpaca can be downloaded from the [GitHub releases page][1]. 32 | 33 | ## Install from distribution packages 34 | 35 | [![Packaging status](https://repology.org/badge/vertical-allrepos/alpaca-proxy.svg)](https://repology.org/project/alpaca-proxy/versions) 36 | 37 | ## Usage 38 | 39 | Start Alpaca by running the `alpaca` binary. 40 | 41 | If the proxy server requires valid authentication credentials, you can provide them by means of: 42 | 43 | - the shell prompt, if `-d` is passed, 44 | - the shell environment, if `NTLM_CREDENTIALS` is set, 45 | - the system keyring (macOS, Windows and Linux/GNOME supported), if none of the above applies. 46 | 47 | Otherwise, the authentication with proxy will be simply ignored. 48 | 49 | ### Shell Prompt 50 | 51 | You can also supply your domain and username (via command-line flags) and a 52 | password (via a prompt): 53 | 54 | ```sh 55 | $ alpaca -d MYDOMAIN -u me 56 | Password (for MYDOMAIN\me): 57 | ``` 58 | 59 | ### Non-interactive launch 60 | 61 | If you want to use Alpaca without any interactive password prompt, you can store 62 | your NTLM credentials (domain, username and MD4-hashed password) in an 63 | environment variable called `$NTLM_CREDENTIALS`. You can use the `-H` flag to 64 | generate this value: 65 | 66 | ```sh 67 | $ ./alpaca -d MYDOMAIN -u me -H 68 | # Add this to your ~/.profile (or equivalent) and restart your shell 69 | NTLM_CREDENTIALS="me@MYDOMAIN:823893adfad2cda6e1a414f3ebdf58f7"; export NTLM_CREDENTIALS 70 | ``` 71 | 72 | Note that this hash is *not* cryptographically secure; it's just meant to stop 73 | people from being able to read your password with a quick glance. 74 | 75 | Once you've set this environment variable, you can start Alpaca by running 76 | `./alpaca`. 77 | 78 | ### Keyring 79 | 80 | On macOS, if you use [NoMAD](https://nomad.menu/products/#nomad) and have configured it 81 | to [use the keychain](https://nomad.menu/help/keychain-usage/), Alpaca will use 82 | these credentials to authenticate to any NTLM challenge from your proxies. 83 | 84 | On Windows and Linux/GNOME you will need some extra work to persist the username (`NTLM_USERNAME`) and the domain (`NTLM_DOMAIN`) 85 | in the shell environoment, while the password in the system keyring. Alpaca will read the password from the system keyring 86 | (in the `login` collection) using the attributes `service=alpaca` and `username=$NTLM_USERNAME`. 87 | 88 | To store the password in the GNOME keyring, do the following: 89 | ```bash 90 | $ export NTLM_USERNAME= 91 | $ export NTLM_DOMAIN= 92 | $ sudo apt install libsecret-tools 93 | $ secret-tool store -c login -l "NTLM credentials" "service" "alpaca" "username" $NTLM_USERNAME 94 | Password: 95 | # Type your password, then run 96 | $ alpaca 97 | ``` 98 | 99 | On macOS and Linux/GNOME systems, Alpaca uses the PAC URL from your system settings. 100 | If you'd like to override this, or if Alpaca fails to detect your settings, you 101 | can set this manually using the `-C` flag. 102 | 103 | --- 104 | 105 | ### Proxy 106 | 107 | You also need to configure your tools to send requests via Alpaca. Usually this 108 | will require setting the `http_proxy` and `https_proxy` environment variables: 109 | 110 | ```sh 111 | $ export http_proxy=http://localhost:3128 112 | $ export https_proxy=http://localhost:3128 113 | $ curl -s https://raw.githubusercontent.com/samuong/alpaca/master/README.md 114 | # Alpaca 115 | ... 116 | ``` 117 | 118 | When moving from, say, a corporate network to a public WiFi network (or 119 | vice-versa), the proxies listed in the PAC script might become unreachable. 120 | When this happens, Alpaca will temporarily bypass the parent proxy and send 121 | requests directly, so there's no need to manually unset/re-set `http_proxy` and 122 | `https_proxy` as you move between networks. 123 | 124 | [1]: https://github.com/samuong/alpaca/releases 125 | [2]: https://img.shields.io/github/v/tag/samuong/alpaca.svg?logo=github&label=latest 126 | [3]: https://img.shields.io/github/actions/workflow/status/samuong/alpaca/ci.yml?branch=master 127 | [4]: https://img.shields.io/github/downloads/samuong/alpaca/latest/total 128 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2022, 2025 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "crypto/tls" 19 | "flag" 20 | "fmt" 21 | "log" 22 | "net" 23 | "net/http" 24 | "os" 25 | "os/user" 26 | "strconv" 27 | ) 28 | 29 | var BuildVersion string 30 | 31 | func whoAmI() string { 32 | me, err := user.Current() 33 | if err != nil { 34 | return "" 35 | } 36 | return me.Username 37 | } 38 | 39 | type stringArrayFlag []string 40 | 41 | func (s *stringArrayFlag) String() string { 42 | return fmt.Sprintf("%v", *s) 43 | } 44 | 45 | func (s *stringArrayFlag) Set(value string) error { 46 | if value == "" { 47 | return nil 48 | } 49 | *s = append(*s, value) 50 | return nil 51 | } 52 | 53 | func main() { 54 | log.SetFlags(log.LstdFlags | log.Lshortfile | log.Lmicroseconds) 55 | 56 | var hosts stringArrayFlag 57 | flag.Var(&hosts, "l", "address to listen on") 58 | port := flag.Int("p", 3128, "port number to listen on") 59 | pacurl := flag.String("C", "", "url of proxy auto-config (pac) file") 60 | domain := flag.String("d", "", "domain of the proxy account (for NTLM auth)") 61 | username := flag.String("u", whoAmI(), "username of the proxy account (for NTLM auth)") 62 | printHash := flag.Bool("H", false, "print hashed NTLM credentials for non-interactive use") 63 | version := flag.Bool("version", false, "print version number") 64 | flag.Parse() 65 | 66 | // default to localhost if no hosts are specified 67 | if len(hosts) == 0 { 68 | hosts = append(hosts, "localhost") 69 | } 70 | 71 | if *version { 72 | fmt.Println("Alpaca", BuildVersion) 73 | os.Exit(0) 74 | } 75 | 76 | var src credentialSource 77 | if *domain != "" { 78 | src = fromTerminal().forUser(*domain, *username) 79 | } else if value := os.Getenv("NTLM_CREDENTIALS"); value != "" { 80 | src = fromEnvVar(value) 81 | } else { 82 | src = fromKeyring() 83 | } 84 | 85 | var a *authenticator 86 | if src != nil { 87 | var err error 88 | a, err = src.getCredentials() 89 | if err != nil { 90 | log.Printf("Credentials not found, disabling proxy auth: %v", err) 91 | } 92 | } 93 | 94 | if *printHash { 95 | if a == nil { 96 | fmt.Println("Please specify a domain (using -d) and username (using -u)") 97 | os.Exit(1) 98 | } 99 | fmt.Printf("# Add this to your ~/.profile (or equivalent) and restart your shell\n") 100 | fmt.Printf("NTLM_CREDENTIALS=%q; export NTLM_CREDENTIALS\n", a) 101 | os.Exit(0) 102 | } 103 | 104 | errch := make(chan error) 105 | 106 | s := createServer(*port, *pacurl, a) 107 | for _, host := range hosts { 108 | address := net.JoinHostPort(host, strconv.Itoa(*port)) 109 | for _, network := range networks(host) { 110 | go func(network string) { 111 | l, err := net.Listen(network, address) 112 | if err != nil { 113 | errch <- err 114 | } else { 115 | log.Printf("Listening on %s %s", network, address) 116 | errch <- s.Serve(l) 117 | } 118 | }(network) 119 | } 120 | } 121 | 122 | log.Fatal(<-errch) 123 | } 124 | 125 | func createServer(port int, pacurl string, a *authenticator) *http.Server { 126 | pacWrapper := NewPACWrapper(PACData{Port: port}) 127 | proxyFinder := NewProxyFinder(pacurl, pacWrapper) 128 | proxyHandler := NewProxyHandler(a, getProxyFromContext, proxyFinder.blockProxy) 129 | mux := http.NewServeMux() 130 | pacWrapper.SetupHandlers(mux) 131 | 132 | // build the handler by wrapping middleware upon middleware 133 | var handler http.Handler = mux 134 | handler = RequestLogger(handler) 135 | handler = proxyHandler.WrapHandler(handler) 136 | handler = proxyFinder.WrapHandler(handler) 137 | handler = AddContextID(handler) 138 | 139 | return &http.Server{ 140 | Handler: handler, 141 | // TODO: Implement HTTP/2 support. In the meantime, set TLSNextProto to a non-nil 142 | // value to disable HTTP/2. 143 | TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), 144 | } 145 | } 146 | 147 | func networks(hostname string) []string { 148 | if hostname == "" { 149 | return []string{"tcp"} 150 | } 151 | addrs, err := net.LookupIP(hostname) 152 | if err != nil { 153 | log.Fatal(err) 154 | } 155 | nets := make([]string, 0, 2) 156 | ipv4 := false 157 | ipv6 := false 158 | for _, addr := range addrs { 159 | // addr == net.IPv4len doesn't work because all addrs use IPv6 format. 160 | if addr.To4() != nil { 161 | ipv4 = true 162 | } else { 163 | ipv6 = true 164 | } 165 | } 166 | if ipv4 { 167 | nets = append(nets, "tcp4") 168 | } 169 | if ipv6 { 170 | nets = append(nets, "tcp6") 171 | } 172 | return nets 173 | } 174 | -------------------------------------------------------------------------------- /netmonitor_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2024 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "errors" 19 | "math/rand/v2" 20 | "net" 21 | "testing" 22 | "time" 23 | 24 | "github.com/stretchr/testify/assert" 25 | ) 26 | 27 | // In order to test netMonitor, we use mock implementations of the 28 | // net.InterfaceAddrs() and net.Dial() functions, as well as the net.Addr and 29 | // net.Conn types. The mocks below will implement just enough functionality to 30 | // allow the tests to run, and will panic if unimplemented functions are 31 | // called. We simulate three network states: "offline", "wifi" and "vpn". 32 | // 33 | // In "offline" mode, only the loopback addresses exist, and attempts to dial 34 | // anywhere will result in a "network is unreachable" error. 35 | // 36 | // In "wifi" mode, in addition to the loopback addresses, we've also got an IP 37 | // address in the 192.168.1.0/24 range, which is meant to look like a home wifi 38 | // router, and we simulate a routing table that routes everything through this 39 | // interface. 40 | // 41 | // In "vpn" mode, we've got the same IP addresses as in "wifi" mode, and any 42 | // connection attempts will be routed via an address in the 10.0.0.0/8 range 43 | // (this is meant to look like a private corporate network). Note that our 44 | // 10.0.0.0/8 address does *not* appear in the output of net.InterfaceAddrs() 45 | // because apparently some VPN clients behave like this. 46 | 47 | type mockAddr string 48 | 49 | func (a mockAddr) Network() string { 50 | return "ip+net" 51 | } 52 | 53 | func (a mockAddr) String() string { 54 | return string(a) 55 | } 56 | 57 | func toAddrs(ss ...string) []net.Addr { 58 | addrs := make([]net.Addr, len(ss)) 59 | for i, s := range ss { 60 | addrs[i] = mockAddr(s) 61 | } 62 | return addrs 63 | } 64 | 65 | type mockConn struct { 66 | localAddr net.Addr 67 | } 68 | 69 | var _ net.Conn = mockConn{} 70 | 71 | func (c mockConn) Close() error { 72 | return nil 73 | } 74 | 75 | func (c mockConn) LocalAddr() net.Addr { 76 | return c.localAddr 77 | } 78 | 79 | func (c mockConn) Read(b []byte) (n int, err error) { 80 | panic("unreachable") 81 | } 82 | 83 | func (c mockConn) RemoteAddr() net.Addr { 84 | panic("unreachable") 85 | } 86 | 87 | func (c mockConn) SetDeadline(t time.Time) error { 88 | panic("unreachable") 89 | } 90 | 91 | func (c mockConn) SetReadDeadline(t time.Time) error { 92 | panic("unreachable") 93 | } 94 | 95 | func (c mockConn) SetWriteDeadline(t time.Time) error { 96 | panic("unreachable") 97 | } 98 | 99 | func (c mockConn) Write(b []byte) (n int, err error) { 100 | panic("unreachable") 101 | } 102 | 103 | type mockNet struct { 104 | state string 105 | } 106 | 107 | func (n *mockNet) interfaceAddrs() ([]net.Addr, error) { 108 | var addrs []net.Addr 109 | switch n.state { 110 | case "vpn", "wifi": 111 | addrs = append(addrs, toAddrs("192.168.1.2/24", "fe80::fedc:ba98:7654:3210/64")...) 112 | fallthrough 113 | case "offline": 114 | addrs = append(addrs, toAddrs("127.0.0.1/8", "::1/128")...) 115 | default: 116 | panic("interfaceAddrs state=" + n.state) 117 | } 118 | return addrs, nil 119 | } 120 | 121 | func (n *mockNet) dial(network, address string) (net.Conn, error) { 122 | if network != "udp" && network != "udp4" { 123 | panic("dial network=" + network) 124 | } 125 | host, _, err := net.SplitHostPort(address) 126 | if err != nil { 127 | panic("dial: " + err.Error()) 128 | } 129 | ip := net.ParseIP(host) 130 | if ip == nil { 131 | panic("dial host=" + host) 132 | } 133 | if ipv4 := ip.To4(); ipv4 == nil { 134 | // Pretend we can't route to any IPv6 addresses. 135 | return nil, newDialError(network, address, "connect: no route to host") 136 | } 137 | switch n.state { 138 | case "vpn": 139 | // Pretend we're routing through a corporate VPN. 140 | return newMockConn(10, 0, 0, 3), nil 141 | case "wifi": 142 | // Pretend we're routing through a home WiFi router. 143 | return newMockConn(192, 168, 1, 2), nil 144 | case "offline": 145 | // Pretend we can't route to anywhere. 146 | return nil, newDialError(network, address, "connect: network is unreachable") 147 | default: 148 | panic("dial state=" + n.state) 149 | } 150 | } 151 | 152 | func newMockConn(a, b, c, d byte) mockConn { 153 | // Pretend that the operating system has assigned a random port (in the 154 | // range 1024 to 65535) on the outbound connection. This allows us to 155 | // test that Alpaca doesn't think the routing table has changed just 156 | // because the port is different on each call to net.Dial(); we only 157 | // need to consider the outgoing IP address without the port number. 158 | return mockConn{ 159 | localAddr: &net.UDPAddr{ 160 | IP: net.IPv4(a, b, c, d), 161 | Port: rand.IntN(65535-1024) + 1024, 162 | }, 163 | } 164 | } 165 | 166 | func newDialError(network, address, text string) *net.OpError { 167 | return &net.OpError{ 168 | Op: "dial", 169 | Net: network, 170 | Source: nil, 171 | Addr: mockAddr(address), 172 | Err: errors.New(text), 173 | } 174 | } 175 | 176 | func TestNetworkMonitor(t *testing.T) { 177 | var network mockNet 178 | nm := &netMonitorImpl{getAddrs: network.interfaceAddrs, dial: network.dial} 179 | network.state = "offline" 180 | assert.True(t, nm.addrsChanged()) 181 | network.state = "wifi" 182 | assert.True(t, nm.addrsChanged()) 183 | assert.False(t, nm.addrsChanged()) 184 | network.state = "vpn" 185 | assert.True(t, nm.addrsChanged()) 186 | network.state = "offline" 187 | assert.True(t, nm.addrsChanged()) 188 | } 189 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Running an integration test requires a squid binary, which isn't available on 16 | // GitHub Actions runners, nor is it likely to be available on random developer 17 | // machines. So the tests in this file are disabled by default using a build 18 | // constraint, and need to be run using `go test ./... -tags=squid`. 19 | 20 | // +build squid 21 | 22 | package main 23 | 24 | import ( 25 | "bytes" 26 | "crypto/ecdsa" 27 | "crypto/elliptic" 28 | "crypto/rand" 29 | "crypto/tls" 30 | "crypto/x509" 31 | "crypto/x509/pkix" 32 | "encoding/pem" 33 | "fmt" 34 | "io" 35 | "log" 36 | "math/big" 37 | "net" 38 | "net/http" 39 | "net/http/httptest" 40 | "net/url" 41 | "os" 42 | "os/exec" 43 | "path/filepath" 44 | "strconv" 45 | "testing" 46 | "time" 47 | 48 | "github.com/stretchr/testify/assert" 49 | "github.com/stretchr/testify/require" 50 | ) 51 | 52 | func findAvailablePort(t *testing.T) string { 53 | l, err := net.Listen("tcp", "localhost:0") 54 | require.NoError(t, err) 55 | defer l.Close() 56 | _, port, err := net.SplitHostPort(l.Addr().String()) 57 | require.NoError(t, err) 58 | return port 59 | } 60 | 61 | func configureSquid(t *testing.T, dir string, cert []byte, key interface{}) string { 62 | certFile, err := os.Create(filepath.Join(dir, "cert.pem")) 63 | require.NoError(t, err) 64 | defer certFile.Close() 65 | require.NoError(t, pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: cert})) 66 | 67 | keyFile, err := os.Create(filepath.Join(dir, "key.pem")) 68 | require.NoError(t, err) 69 | defer keyFile.Close() 70 | keyBytes, err := x509.MarshalPKCS8PrivateKey(key) 71 | require.NoError(t, err) 72 | require.NoError(t, pem.Encode(keyFile, &pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})) 73 | 74 | httpsPort := findAvailablePort(t) 75 | 76 | // TODO: configure squid to require NTLM authentication 77 | // https://wiki.squid-cache.org/ConfigExamples/Authenticate/Ntlm 78 | squidConf, err := os.Create(filepath.Join(dir, "squid.conf")) 79 | require.NoError(t, err) 80 | defer squidConf.Close() 81 | fmt.Fprintf(squidConf, "access_log access.log\n") 82 | fmt.Fprintf(squidConf, "cache_log cache.log\n") 83 | fmt.Fprintf(squidConf, "pid_filename none\n") 84 | fmt.Fprintf(squidConf, "http_access allow localhost\n") 85 | fmt.Fprintf(squidConf, "http_access deny all\n") 86 | fmt.Fprintf(squidConf, "https_port %s cert=cert.pem key=key.pem\n", httpsPort) 87 | 88 | return httpsPort 89 | } 90 | 91 | func writeFileToLog(t *testing.T, path string) error { 92 | buf, err := os.ReadFile(path) 93 | if err != nil { 94 | return err 95 | } 96 | t.Logf("%s:\n%s", path, string(buf)) 97 | return nil 98 | } 99 | 100 | func waitForServer(address string) error { 101 | delays := []time.Duration{ 102 | 0, 103 | 50 * time.Millisecond, 104 | 500 * time.Millisecond, 105 | 5 * time.Second, 106 | } 107 | var err error 108 | for _, delay := range delays { 109 | time.Sleep(delay) 110 | var conn net.Conn 111 | if conn, err = net.Dial("tcp", address); err != nil { 112 | continue 113 | } 114 | conn.Close() 115 | return nil 116 | } 117 | return err 118 | } 119 | 120 | func serveString(s string) http.HandlerFunc { 121 | return func(w http.ResponseWriter, req *http.Request) { 122 | w.Write([]byte(s)) 123 | } 124 | } 125 | 126 | func TestWithSquid(t *testing.T) { 127 | // This is an integration test that sets up the following components: 128 | // 129 | // +-> pac server 130 | // | 131 | // http client -> alpaca -+-> squid -> https server 132 | // 133 | // The connection between alpaca and squid is TLS-encrypted, as is the 134 | // connection between the http client and the https server. In both 135 | // cases, self-signed certs are generated and configured in-test. 136 | 137 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 138 | require.NoError(t, err) 139 | template := x509.Certificate{ 140 | SerialNumber: &big.Int{}, 141 | Subject: pkix.Name{Organization: []string{"Alpaca, Inc."}}, 142 | NotBefore: time.Now(), 143 | NotAfter: time.Now().Add(time.Hour), 144 | IsCA: true, 145 | DNSNames: []string{"localhost"}, 146 | IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, 147 | } 148 | cert, err := x509.CreateCertificate(rand.Reader, &template, &template, key.Public(), key) 149 | require.NoError(t, err) 150 | 151 | tempDir, err := os.MkdirTemp("", "squid") 152 | require.NoError(t, err) 153 | defer os.RemoveAll(tempDir) 154 | 155 | httpsPort := configureSquid(t, tempDir, cert, key) 156 | defer writeFileToLog(t, filepath.Join(tempDir, "access.log")) 157 | defer writeFileToLog(t, filepath.Join(tempDir, "cache.log")) 158 | 159 | cmd := exec.Command("squid", "-f", "squid.conf", "-N") 160 | cmd.Dir = tempDir 161 | require.NoError(t, cmd.Start()) 162 | defer cmd.Process.Kill() 163 | waitForServer("localhost:" + httpsPort) 164 | 165 | httpsServer := httptest.NewTLSServer(serveString(fmt.Sprintf("https server"))) 166 | defer httpsServer.Close() 167 | 168 | pacScript := fmt.Sprintf( 169 | `function FindProxyForURL(url, host) { return "HTTPS localhost:%s"; }`, 170 | httpsPort, 171 | ) 172 | pacServer := httptest.NewServer(serveString(pacScript)) 173 | defer pacServer.Close() 174 | t.Logf("pac server is available on %s", pacServer.URL) 175 | 176 | // Squid is set up to use a self-signed certificate; configure Alpaca 177 | // to accept it. 178 | tlsClientConfig = &tls.Config{RootCAs: x509.NewCertPool()} 179 | c, err := x509.ParseCertificate(cert) 180 | require.NoError(t, err) 181 | tlsClientConfig.RootCAs.AddCert(c) 182 | 183 | // Alpaca logs to stderr by default; redirect to a buffer and write to 184 | // the test log in case it's useful for debugging. 185 | var logs bytes.Buffer 186 | log.SetFlags(log.LstdFlags | log.Lshortfile) 187 | log.SetOutput(&logs) 188 | defer func() { t.Logf("alpaca logs:\n%s", logs.String()) }() 189 | 190 | // Run (most of) Alpaca in a goroutine. 191 | port, err := strconv.Atoi(findAvailablePort(t)) 192 | require.NoError(t, err) 193 | alpaca := createServer("localhost", port, pacServer.URL, nil) 194 | go alpaca.ListenAndServe() 195 | defer alpaca.Close() 196 | waitForServer(alpaca.Addr) 197 | t.Logf("alpaca is listening on port %d", port) 198 | 199 | tr := &http.Transport{ 200 | Proxy: http.ProxyURL(&url.URL{Host: alpaca.Addr}), 201 | TLSClientConfig: &tls.Config{RootCAs: x509.NewCertPool()}, 202 | } 203 | tr.TLSClientConfig.RootCAs.AddCert(httpsServer.Certificate()) 204 | 205 | req, err := http.NewRequest(http.MethodGet, httpsServer.URL, nil) 206 | require.NoError(t, err) 207 | resp, err := tr.RoundTrip(req) 208 | require.NoError(t, err) 209 | assert.Equal(t, http.StatusOK, resp.StatusCode) 210 | body, err := io.ReadAll(resp.Body) 211 | require.NoError(t, err) 212 | assert.Equal(t, "https server", string(body)) 213 | } 214 | -------------------------------------------------------------------------------- /proxy.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2022, 2023, 2024 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bytes" 19 | "crypto/tls" 20 | "errors" 21 | "fmt" 22 | "io" 23 | "log" 24 | "net" 25 | "net/http" 26 | "net/url" 27 | "strings" 28 | ) 29 | 30 | var tlsClientConfig *tls.Config 31 | 32 | type ProxyHandler struct { 33 | transport *http.Transport 34 | auth *authenticator 35 | block func(string) 36 | } 37 | 38 | type proxyFunc func(*http.Request) (*url.URL, error) 39 | 40 | func NewProxyHandler(auth *authenticator, proxy proxyFunc, block func(string)) ProxyHandler { 41 | tr := &http.Transport{Proxy: proxy, TLSClientConfig: tlsClientConfig} 42 | return ProxyHandler{tr, auth, block} 43 | } 44 | 45 | func (ph ProxyHandler) WrapHandler(next http.Handler) http.Handler { 46 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 47 | // Pass CONNECT requests and absolute-form URIs to the ProxyHandler. 48 | // If the request URL has a scheme, it is an absolute-form URI 49 | // (RFC 7230 Section 5.3.2). 50 | if req.Method == http.MethodConnect || req.URL.Scheme != "" { 51 | ph.ServeHTTP(w, req) 52 | return 53 | } 54 | // The request URI is an origin-form or asterisk-form target which we 55 | // handle as an origin server (RFC 7230 5.3). authority-form URIs 56 | // are only for CONNECT, which has already been dispatched to the 57 | // ProxyHandler. 58 | next.ServeHTTP(w, req) 59 | }) 60 | } 61 | 62 | func (ph ProxyHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 63 | deleteRequestHeaders(req) 64 | if req.Method == http.MethodConnect { 65 | ph.handleConnect(w, req) 66 | } else { 67 | ph.proxyRequest(w, req, ph.auth) 68 | } 69 | } 70 | 71 | func (ph ProxyHandler) handleConnect(w http.ResponseWriter, req *http.Request) { 72 | // Establish a connection to the server, or an upstream proxy. 73 | id := req.Context().Value(contextKeyID) 74 | proxy, err := ph.transport.Proxy(req) 75 | if err != nil { 76 | log.Printf("[%d] Error finding proxy for request: %v", id, err) 77 | } 78 | var server net.Conn 79 | if proxy == nil { 80 | server, err = connectDirect(req) 81 | } else { 82 | server, err = connectViaProxy(req, proxy, ph.auth) 83 | var oe *net.OpError 84 | if errors.As(err, &oe) && oe.Op == "proxyconnect" { 85 | log.Printf("[%d] Temporarily blocking proxy: %q", id, proxy.Host) 86 | ph.block(proxy.Host) 87 | } 88 | } 89 | if err != nil { 90 | w.WriteHeader(http.StatusBadGateway) 91 | return 92 | } 93 | closeInDefer := true 94 | defer func() { 95 | if closeInDefer { 96 | server.Close() 97 | } 98 | }() 99 | // Take over the connection back to the client by hijacking the ResponseWriter. 100 | h, ok := w.(http.Hijacker) 101 | if !ok { 102 | log.Printf("[%d] Error hijacking response writer", id) 103 | w.WriteHeader(http.StatusInternalServerError) 104 | return 105 | } 106 | client, _, err := h.Hijack() 107 | if err != nil { 108 | log.Printf("[%d] Error hijacking connection: %v", id, err) 109 | w.WriteHeader(http.StatusInternalServerError) 110 | return 111 | } 112 | defer func() { 113 | if closeInDefer { 114 | client.Close() 115 | } 116 | }() 117 | // Write the response directly to the client connection. If we use Go's ResponseWriter, it 118 | // will automatically insert a Content-Length header, which is not allowed in a 2xx CONNECT 119 | // response (see https://tools.ietf.org/html/rfc7231#section-4.3.6). 120 | var resp []byte 121 | if req.ProtoAtLeast(1, 1) { 122 | resp = []byte("HTTP/1.1 200 Connection Established\r\n\r\n") 123 | } else { 124 | resp = []byte("HTTP/1.0 200 Connection Established\r\n\r\n") 125 | } 126 | if _, err := client.Write(resp); err != nil { 127 | log.Printf("[%d] Error writing response: %v", id, err) 128 | return 129 | } 130 | // Kick off goroutines to copy data in each direction. Whichever goroutine finishes first 131 | // will close the Reader for the other goroutine, forcing any blocked copy to unblock. This 132 | // prevents any goroutine from blocking indefinitely (which will leak a file descriptor). 133 | closeInDefer = false 134 | go func() { _, _ = io.Copy(server, client); server.Close() }() 135 | go func() { _, _ = io.Copy(client, server); client.Close() }() 136 | } 137 | 138 | func connectDirect(req *http.Request) (net.Conn, error) { 139 | server, err := net.Dial("tcp", req.Host) 140 | if err != nil { 141 | id := req.Context().Value(contextKeyID) 142 | log.Printf("[%d] Error dialling host %s: %v", id, req.Host, err) 143 | } 144 | return server, err 145 | } 146 | 147 | func connectViaProxy(req *http.Request, proxy *url.URL, auth *authenticator) (net.Conn, error) { 148 | id := req.Context().Value(contextKeyID) 149 | var tr transport 150 | defer tr.Close() 151 | if err := tr.dial(proxy); err != nil { 152 | log.Printf("[%d] Error dialling proxy %s: %v", id, proxy.Host, err) 153 | return nil, err 154 | } 155 | resp, err := tr.RoundTrip(req) 156 | if err != nil { 157 | log.Printf("[%d] Error reading CONNECT response: %v", id, err) 158 | return nil, err 159 | } else if resp.StatusCode == http.StatusProxyAuthRequired && auth != nil { 160 | log.Printf("[%d] Got %q response, retrying with auth", id, resp.Status) 161 | resp.Body.Close() 162 | if err := tr.dial(proxy); err != nil { 163 | log.Printf("[%d] Error re-dialling %s: %v", id, proxy.Host, err) 164 | return nil, err 165 | } 166 | resp, err = auth.do(req, &tr) 167 | if err != nil { 168 | return nil, err 169 | } 170 | log.Printf("[%d] Got %q response", id, resp.Status) 171 | } 172 | resp.Body.Close() 173 | if resp.StatusCode != http.StatusOK { 174 | return nil, fmt.Errorf("[%d] Unexpected response status: %s", id, resp.Status) 175 | } 176 | return tr.hijack(), nil 177 | } 178 | 179 | func (ph ProxyHandler) proxyRequest(w http.ResponseWriter, req *http.Request, auth *authenticator) { 180 | // Make a copy of the request body, in case we have to replay it (for authentication) 181 | var buf bytes.Buffer 182 | id := req.Context().Value(contextKeyID) 183 | if n, err := io.Copy(&buf, req.Body); err != nil { 184 | log.Printf("[%d] Error copying request body (got %d/%d): %v", 185 | id, n, req.ContentLength, err) 186 | w.WriteHeader(http.StatusInternalServerError) 187 | return 188 | } 189 | rd := bytes.NewReader(buf.Bytes()) 190 | req.Body = io.NopCloser(rd) 191 | resp, err := ph.transport.RoundTrip(req) 192 | if err != nil { 193 | log.Printf("[%d] Error forwarding request: %v", id, err) 194 | w.WriteHeader(http.StatusBadGateway) 195 | var oe *net.OpError 196 | if errors.As(err, &oe) && oe.Op == "proxyconnect" { 197 | proxy, err := ph.transport.Proxy(req) 198 | if err != nil { 199 | log.Printf("[%d] Proxy connect error to unknown proxy: %v", id, err) 200 | return 201 | } 202 | log.Printf("[%d] Temporarily blocking proxy: %q", id, proxy.Host) 203 | ph.block(proxy.Host) 204 | } 205 | return 206 | } 207 | if resp.StatusCode == http.StatusProxyAuthRequired && auth != nil { 208 | resp.Body.Close() 209 | log.Printf("[%d] Got %q response, retrying with auth", id, resp.Status) 210 | _, err = rd.Seek(0, io.SeekStart) 211 | if err != nil { 212 | log.Printf("[%d] Error while seeking to start of request body: %v", id, err) 213 | } else { 214 | req.Body = io.NopCloser(rd) 215 | resp, err = auth.do(req, ph.transport) 216 | if err != nil { 217 | log.Printf("[%d] Error forwarding request (with auth): %v", id, err) 218 | w.WriteHeader(http.StatusBadGateway) 219 | return 220 | } 221 | } 222 | log.Printf("[%d] Got %q response", id, resp.Status) 223 | } 224 | defer resp.Body.Close() 225 | copyResponseHeaders(w, resp) 226 | w.WriteHeader(resp.StatusCode) 227 | _, err = io.Copy(w, resp.Body) 228 | if err != nil { 229 | // The response status has already been sent, so if copying fails, we can't return 230 | // an error status to the client. Instead, log the error. 231 | log.Printf("[%d] Error copying response body: %v", id, err) 232 | return 233 | } 234 | } 235 | 236 | func deleteConnectionTokens(header http.Header) { 237 | // Remove any header field(s) with the same name as a connection token (see 238 | // https://tools.ietf.org/html/rfc2616#section-14.10) 239 | if values, ok := header["Connection"]; ok { 240 | for _, value := range values { 241 | if value == "close" { 242 | continue 243 | } 244 | tokens := strings.Split(value, ",") 245 | for _, token := range tokens { 246 | header.Del(strings.TrimSpace(token)) 247 | } 248 | } 249 | } 250 | } 251 | 252 | func deleteRequestHeaders(req *http.Request) { 253 | // Delete hop-by-hop headers (see https://tools.ietf.org/html/rfc2616#section-13.5.1) 254 | deleteConnectionTokens(req.Header) 255 | req.Header.Del("Connection") 256 | req.Header.Del("Keep-Alive") 257 | req.Header.Del("Proxy-Authorization") 258 | req.Header.Del("TE") 259 | req.Header.Del("Upgrade") 260 | } 261 | 262 | func copyResponseHeaders(w http.ResponseWriter, resp *http.Response) { 263 | for k, vs := range resp.Header { 264 | for _, v := range vs { 265 | w.Header().Add(k, v) 266 | } 267 | } 268 | // Delete hop-by-hop headers (see https://tools.ietf.org/html/rfc2616#section-13.5.1) 269 | deleteConnectionTokens(w.Header()) 270 | w.Header().Del("Connection") 271 | w.Header().Del("Keep-Alive") 272 | w.Header().Del("Proxy-Authenticate") 273 | w.Header().Del("Trailer") 274 | w.Header().Del("Transfer-Encoding") 275 | w.Header().Del("Upgrade") 276 | } 277 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /pacrunner.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2023, 2024, 2025 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "encoding/binary" 19 | "errors" 20 | "net" 21 | "net/url" 22 | "os" 23 | "strings" 24 | "sync" 25 | "time" 26 | 27 | "github.com/gobwas/glob" 28 | "github.com/robertkrimen/otto" 29 | ) 30 | 31 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Proxy_servers_and_tunneling/Proxy_Auto-Configuration_(PAC)_file 32 | 33 | type PACRunner struct { 34 | vm *otto.Otto 35 | sync.Mutex 36 | } 37 | 38 | func (pr *PACRunner) Update(pacjs []byte) error { 39 | vm := otto.New() 40 | var err error 41 | set := func(name string, handler func(otto.FunctionCall) otto.Value) { 42 | if err != nil { 43 | return 44 | } 45 | err = vm.Set(name, handler) 46 | } 47 | set("isPlainHostName", isPlainHostName) 48 | set("dnsDomainIs", dnsDomainIs) 49 | set("localHostOrDomainIs", localHostOrDomainIs) 50 | set("isResolvable", isResolvable) 51 | set("isInNet", isInNet) 52 | set("dnsResolve", dnsResolve) 53 | set("convert_addr", convertAddr) 54 | set("myIpAddress", myIpAddress) 55 | set("myIpAddressEx", myIpAddressEx) 56 | set("dnsDomainLevels", dnsDomainLevels) 57 | set("shExpMatch", shExpMatch) 58 | set("weekdayRange", func(fc otto.FunctionCall) otto.Value { 59 | return weekdayRange(fc, time.Now()) 60 | }) 61 | set("dateRange", func(fc otto.FunctionCall) otto.Value { 62 | return dateRange(fc, time.Now()) 63 | }) 64 | set("timeRange", func(fc otto.FunctionCall) otto.Value { 65 | return timeRange(fc, time.Now()) 66 | }) 67 | if err != nil { 68 | return err 69 | } 70 | _, err = vm.Run(pacjs) 71 | if err != nil { 72 | return err 73 | } 74 | pr.vm = vm 75 | return nil 76 | } 77 | 78 | func (pr *PACRunner) FindProxyForURL(u url.URL) (string, error) { 79 | pr.Lock() 80 | defer pr.Unlock() 81 | if u.Scheme == "" { 82 | // When a net/http Server parses a CONNECT request, the URL will 83 | // have no Scheme. In that case, assume the scheme is "https". 84 | u.Scheme = "https" 85 | } 86 | if u.Scheme == "https" || u.Scheme == "wss" { 87 | // Strip the path and query components of https:// URLs. 88 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Proxy_servers_and_tunneling/Proxy_Auto-Configuration_(PAC)_file#Parameters 89 | // Like Chrome, also strip the path and query for wss:// URLs (secure WebSockets). 90 | // https://cs.chromium.org/chromium/src/net/proxy_resolution/proxy_resolution_service.cc?rcl=fba6691ffca770dd0c916418601b9c9c019a2929&l=383 91 | // It also seems like a good idea to strip the fragment, so do that too. 92 | u.Path = "/" 93 | u.RawPath = "/" 94 | u.RawQuery = "" 95 | u.Fragment = "" 96 | } 97 | val, err := pr.vm.Call("FindProxyForURL", nil, u.String(), u.Hostname()) 98 | if err != nil { 99 | return "", err 100 | } else if !val.IsString() { 101 | return "", errors.New("FindProxyForURL didn't return a string") 102 | } 103 | return val.String(), nil 104 | } 105 | 106 | func toValue(unwrapped interface{}) otto.Value { 107 | wrapped, err := otto.ToValue(unwrapped) 108 | if err != nil { 109 | return otto.UndefinedValue() 110 | } else { 111 | return wrapped 112 | } 113 | } 114 | 115 | func isPlainHostName(call otto.FunctionCall) otto.Value { 116 | host := call.Argument(0).String() 117 | return toValue(!strings.ContainsRune(host, '.')) 118 | } 119 | 120 | func dnsDomainIs(call otto.FunctionCall) otto.Value { 121 | host := call.Argument(0).String() 122 | domain := call.Argument(1).String() 123 | return toValue(strings.HasSuffix(host, domain)) 124 | } 125 | 126 | func localHostOrDomainIs(call otto.FunctionCall) otto.Value { 127 | host := call.Argument(0).String() 128 | hostdom := call.Argument(1).String() 129 | return toValue(host == hostdom || strings.HasPrefix(hostdom, host+".")) 130 | } 131 | 132 | func isResolvable(call otto.FunctionCall) otto.Value { 133 | host := call.Argument(0).String() 134 | _, err := net.LookupHost(host) 135 | return toValue(err == nil) 136 | } 137 | 138 | func isInNet(call otto.FunctionCall) otto.Value { 139 | host := call.Argument(0).String() 140 | pattern := call.Argument(1).String() 141 | mask := call.Argument(2).String() 142 | buf := net.ParseIP(mask).To4() 143 | if len(buf) != 4 { 144 | return toValue(false) 145 | } 146 | 147 | m := net.IPv4Mask(buf[0], buf[1], buf[2], buf[3]) 148 | maskedIP := resolve(host).Mask(m) 149 | maskedPattern := net.ParseIP(pattern).To4().Mask(m) 150 | return toValue(maskedIP.Equal(maskedPattern)) 151 | } 152 | 153 | func dnsResolve(call otto.FunctionCall) otto.Value { 154 | host := call.Argument(0).String() 155 | return toValue(resolve(host).String()) 156 | } 157 | 158 | func resolve(host string) net.IP { 159 | if ip := net.ParseIP(host); ip != nil { 160 | // The given host is already an IP(v4) address; just return it. 161 | return ip.To4() 162 | } 163 | addrs, err := net.LookupHost(host) 164 | if err != nil { 165 | return nil 166 | } 167 | for _, addr := range addrs { 168 | // There might be multiple IP addresses for this host. Return the first IPv4 address 169 | // that we can find. 170 | if ipv4 := net.ParseIP(addr).To4(); ipv4 != nil { 171 | return ipv4 172 | } 173 | } 174 | return nil 175 | } 176 | 177 | func convertAddr(call otto.FunctionCall) otto.Value { 178 | ipaddr := call.Argument(0).String() 179 | ipv4 := net.ParseIP(ipaddr).To4() 180 | if ipv4 == nil { 181 | return toValue(0) 182 | } 183 | return toValue(binary.BigEndian.Uint32(ipv4)) 184 | } 185 | 186 | func myIpAddress(_ otto.FunctionCall) otto.Value { 187 | // https://chromium.googlesource.com/chromium/src/+/ee43fa5328856129f46566b2ea1be5811739681c/net/docs/proxy.md#Resolving-client_s-IP-address-within-a-PAC-script-using-myIpAddress 188 | if localAddr := probeRoute("8.8.8.8"); localAddr != "" { 189 | return toValue(localAddr) 190 | } 191 | if ips := resolveHostname(false); len(ips) > 0 { 192 | return toValue(ips[0].String()) 193 | } 194 | private := []string{"10.0.0.0", "172.16.0.0", "192.168.0.0"} 195 | for _, remoteAddr := range private { 196 | if localAddr := probeRoute(remoteAddr); localAddr != "" { 197 | return toValue(localAddr) 198 | } 199 | } 200 | return toValue("127.0.0.1") 201 | } 202 | 203 | func myIpAddressEx(_ otto.FunctionCall) otto.Value { 204 | // https://chromium.googlesource.com/chromium/src/+/ee43fa5328856129f46566b2ea1be5811739681c/net/docs/proxy.md#resolving-client_s-ip-address-within-a-pac-script-using-myipaddressex 205 | public := []string{"8.8.8.8", "2001:4860:4860::8888"} 206 | if ips := probeRoutes(public); ips != "" { 207 | return toValue(ips) 208 | } 209 | if ips := resolveHostname(true); len(ips) > 0 { 210 | var b strings.Builder 211 | b.WriteString(ips[0].String()) 212 | for _, ip := range ips[1:] { 213 | b.WriteRune(';') 214 | b.WriteString(ip.String()) 215 | } 216 | return toValue(b.String()) 217 | } 218 | private := []string{"10.0.0.0", "172.16.0.0", "192.168.0.0", "FC00::"} 219 | ips := probeRoutes(private) 220 | return toValue(ips) 221 | } 222 | 223 | func probeRoutes(addresses []string) string { 224 | var slice []string 225 | set := map[string]struct{}{} 226 | for _, address := range addresses { 227 | localAddr := probeRoute(address) 228 | if localAddr == "" { 229 | continue 230 | } 231 | if _, ok := set[localAddr]; ok { 232 | continue 233 | } 234 | set[localAddr] = struct{}{} 235 | slice = append(slice, localAddr) 236 | } 237 | return strings.Join(slice, ";") 238 | } 239 | 240 | // probeRoute creates a UDP "connection" to the remote address, and returns the 241 | // local interface address. This does involve a system call, but does not 242 | // generate any network traffic since UDP is a connectionless protocol. 243 | func probeRoute(address string) string { 244 | conn, err := net.Dial("udp", net.JoinHostPort(address, "80")) 245 | if err != nil { 246 | return "" 247 | } 248 | local, ok := conn.LocalAddr().(*net.UDPAddr) 249 | if !ok { 250 | return "" 251 | } 252 | if local.IP.IsLoopback() || 253 | local.IP.IsLinkLocalUnicast() || 254 | local.IP.IsLinkLocalMulticast() { 255 | return "" 256 | } 257 | return local.IP.String() 258 | } 259 | 260 | // resolveHostname does a DNS resolve of the machine's hostname, and filters 261 | // out any loopback and link-local addresses, as well as any IPv6 addresses if 262 | // ipv6 is set to false. 263 | func resolveHostname(ipv6 bool) []net.IP { 264 | host, err := os.Hostname() 265 | if err != nil { 266 | return nil 267 | } 268 | ips, err := net.LookupIP(host) 269 | if err != nil { 270 | return nil 271 | } 272 | var addrs []net.IP 273 | for _, ip := range ips { 274 | if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { 275 | continue 276 | } 277 | if ip.To4() != nil || ipv6 { 278 | addrs = append(addrs, ip) 279 | } 280 | } 281 | return addrs 282 | } 283 | 284 | func dnsDomainLevels(call otto.FunctionCall) otto.Value { 285 | host := call.Argument(0).String() 286 | return toValue(strings.Count(host, ".")) 287 | } 288 | 289 | func shExpMatch(call otto.FunctionCall) otto.Value { 290 | str := call.Argument(0).String() 291 | shexp := call.Argument(1).String() 292 | g, err := glob.Compile(shexp) 293 | if err != nil { 294 | return otto.UndefinedValue() 295 | } 296 | return toValue(g.Match(str)) 297 | } 298 | 299 | func weekdayRange(call otto.FunctionCall, now time.Time) otto.Value { 300 | if call.Argument(len(call.ArgumentList)-1).String() == "GMT" { 301 | now = now.In(time.UTC) 302 | } 303 | weekdays := map[string]time.Weekday{ 304 | "SUN": time.Sunday, "MON": time.Monday, "TUE": time.Tuesday, "WED": time.Wednesday, 305 | "THU": time.Thursday, "FRI": time.Friday, "SAT": time.Saturday, 306 | } 307 | wd1, ok := weekdays[call.Argument(0).String()] 308 | if !ok { 309 | return otto.UndefinedValue() 310 | } 311 | wd2, ok := weekdays[call.Argument(1).String()] 312 | if !ok { 313 | return toValue(now.Weekday() == wd1) 314 | } else if wd1 <= wd2 { 315 | return toValue(wd1 <= now.Weekday() && now.Weekday() <= wd2) 316 | } else { 317 | return toValue(wd1 == now.Weekday() || wd2 == now.Weekday()) 318 | } 319 | } 320 | 321 | func dateRange(call otto.FunctionCall, now time.Time) otto.Value { 322 | argc := len(call.ArgumentList) 323 | if call.Argument(argc-1).String() == "GMT" { 324 | now = now.In(time.UTC) 325 | argc-- 326 | } 327 | 328 | var days []int 329 | var months []time.Month 330 | var years []int 331 | 332 | monthmap := map[string]time.Month{ 333 | "JAN": time.January, "FEB": time.February, "MAR": time.March, 334 | "APR": time.April, "MAY": time.May, "JUN": time.June, 335 | "JUL": time.July, "AUG": time.August, "SEP": time.September, 336 | "OCT": time.October, "NOV": time.November, "DEC": time.December, 337 | } 338 | 339 | for i := 0; i < argc; i++ { 340 | if call.Argument(i).IsNumber() { 341 | n, err := call.Argument(i).ToInteger() 342 | if err != nil { 343 | return otto.UndefinedValue() 344 | } else if 1 <= n && n <= 31 { 345 | days = append(days, int(n)) 346 | } else { 347 | years = append(years, int(n)) 348 | } 349 | } else if month, ok := monthmap[call.Argument(i).String()]; ok { 350 | months = append(months, month) 351 | } else { 352 | return otto.UndefinedValue() 353 | } 354 | } 355 | 356 | switch max(len(days), len(months), len(years)) { 357 | case 1: 358 | // One (possibly partial) date provided; match it against the current date. 359 | if len(days) == 1 && days[0] != now.Day() { 360 | return otto.FalseValue() 361 | } else if len(months) == 1 && months[0] != now.Month() { 362 | return otto.FalseValue() 363 | } else if len(years) == 1 && years[0] != now.Year() { 364 | return otto.FalseValue() 365 | } else { 366 | return otto.TrueValue() 367 | } 368 | case 2: 369 | // Two dates provided; check that the current date is inside the range. 370 | y1, m1, d1 := now.Date() 371 | y2, m2, d2 := now.Date() 372 | if len(days) == 2 { 373 | d1, d2 = days[0], days[1] 374 | } 375 | if len(months) == 2 { 376 | m1, m2 = months[0], months[1] 377 | } 378 | if len(years) == 2 { 379 | y1, y2 = years[0], years[1] 380 | } 381 | h, m, s := now.Clock() 382 | ns, loc := now.Nanosecond(), now.Location() 383 | start := time.Date(y1, m1, d1, h, m, s, ns, loc) 384 | end := time.Date(y2, m2, d2, h, m, s, ns, loc) 385 | return toValue(!start.After(now) && !end.Before(now)) 386 | default: 387 | // Zero, three or more dates provided. Something's wrong. 388 | return otto.UndefinedValue() 389 | } 390 | } 391 | 392 | func max(a, b, c int) int { 393 | if a >= b && a >= c { 394 | return a 395 | } else if b >= c { 396 | return b 397 | } else { 398 | return c 399 | } 400 | } 401 | 402 | func timeRange(call otto.FunctionCall, now time.Time) otto.Value { 403 | argc := len(call.ArgumentList) 404 | if call.Argument(argc-1).String() == "GMT" { 405 | now = now.In(time.UTC) 406 | argc-- 407 | } 408 | h1, m1, s1, h2, m2, s2 := 0, 0, 0, 0, 0, 0 409 | var err error 410 | toInt := func(idx int) int { 411 | val, err2 := call.Argument(idx).ToInteger() 412 | if err2 != nil { 413 | err = err2 414 | } 415 | return int(val) 416 | } 417 | switch argc { 418 | case 1: 419 | h1 = toInt(0) 420 | h2 = h1 + 1 421 | case 2: 422 | h1 = toInt(0) 423 | h2 = toInt(1) 424 | case 4: 425 | h1, m1 = toInt(0), toInt(1) 426 | h2, m2 = toInt(2), toInt(3) 427 | case 6: 428 | h1, m1, s1 = toInt(0), toInt(1), toInt(2) 429 | h2, m2, s2 = toInt(3), toInt(4), toInt(5) 430 | default: 431 | return otto.UndefinedValue() 432 | } 433 | if err != nil { 434 | return otto.UndefinedValue() 435 | } 436 | start := time.Date(now.Year(), now.Month(), now.Day(), h1, m1, s1, 0, now.Location()) 437 | end := time.Date(now.Year(), now.Month(), now.Day(), h2, m2, s2, 0, now.Location()) 438 | return toValue(!start.After(now) && end.After(now)) 439 | } 440 | -------------------------------------------------------------------------------- /proxy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2021, 2022 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bufio" 19 | "context" 20 | "crypto/tls" 21 | "crypto/x509" 22 | "fmt" 23 | "io" 24 | "net" 25 | "net/http" 26 | "net/http/httptest" 27 | "net/url" 28 | "strings" 29 | "testing" 30 | 31 | "github.com/stretchr/testify/assert" 32 | "github.com/stretchr/testify/require" 33 | ) 34 | 35 | type requestLogger struct { 36 | requests []string 37 | } 38 | 39 | func (r *requestLogger) clear() { 40 | r.requests = nil 41 | } 42 | 43 | func (r *requestLogger) log(name string, delegate http.Handler) http.Handler { 44 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 45 | r.requests = append(r.requests, fmt.Sprintf("%s to %s", req.Method, name)) 46 | delegate.ServeHTTP(w, req) 47 | }) 48 | } 49 | 50 | func TestProxy(t *testing.T) { 51 | // This test sets up the following components: 52 | // 53 | // client -+-------> parent proxy -+-> server 54 | // | ^ | 55 | // | | +-> tls server 56 | // +- child proxy -+ 57 | // 58 | // There are two servers - a regular HTTP server as well as an HTTPS 59 | // (TLS) server - so that we can test both regular HTTP methods as well 60 | // as the CONNECT method. 61 | // 62 | // There are two proxies, and the client can either use just the parent 63 | // proxy, or both the parent and child proxies. This simulates the 64 | // cases where Alpaca acts as a direct or chained proxy. 65 | 66 | var r requestLogger 67 | server := httptest.NewServer(r.log("server", http.NewServeMux())) 68 | defer server.Close() 69 | tlsServer := httptest.NewTLSServer(r.log("tlsServer", http.NewServeMux())) 70 | defer tlsServer.Close() 71 | parentProxy := httptest.NewServer(r.log("parentProxy", newDirectProxy())) 72 | defer parentProxy.Close() 73 | childProxy := httptest.NewServer(r.log("childProxy", newChildProxy(parentProxy))) 74 | defer childProxy.Close() 75 | 76 | for _, test := range []struct { 77 | name string 78 | proxy *httptest.Server 79 | server *httptest.Server 80 | requests []string 81 | }{ 82 | { 83 | // client -> parent proxy -> server 84 | "ClientToProxyToServer", parentProxy, server, 85 | []string{"GET to parentProxy", "GET to server"}, 86 | }, { 87 | // client -> parent proxy -> tls server 88 | "ClientToProxyToTLSServer", parentProxy, tlsServer, 89 | []string{"CONNECT to parentProxy", "GET to tlsServer"}, 90 | }, { 91 | // client -> child proxy -> parent proxy -> server 92 | "ClientToProxyToProxyToServer", childProxy, server, 93 | []string{"GET to childProxy", "GET to parentProxy", "GET to server"}, 94 | }, { 95 | // client -> child proxy -> parent proxy -> tls server 96 | "ClientToProxyToProxyToTLSServer", childProxy, tlsServer, 97 | []string{ 98 | "CONNECT to childProxy", 99 | "CONNECT to parentProxy", 100 | "GET to tlsServer", 101 | }, 102 | }, 103 | } { 104 | t.Run(test.name, func(t *testing.T) { 105 | r.clear() 106 | client := &http.Client{ 107 | Transport: &http.Transport{ 108 | Proxy: proxyServer(t, test.proxy), 109 | TLSClientConfig: tlsConfig(tlsServer), 110 | }, 111 | } 112 | resp, err := client.Get(test.server.URL) 113 | require.NoError(t, err) 114 | defer resp.Body.Close() 115 | assert.Equal(t, test.requests, r.requests) 116 | }) 117 | } 118 | } 119 | 120 | func TestProxyHTTP2(t *testing.T) { 121 | var r requestLogger 122 | server := httptest.NewUnstartedServer(r.log("server", http.NewServeMux())) 123 | server.EnableHTTP2 = true 124 | server.StartTLS() 125 | defer server.Close() 126 | proxy := httptest.NewServer(r.log("proxy", newDirectProxy())) 127 | defer proxy.Close() 128 | client := &http.Client{ 129 | Transport: &http.Transport{ 130 | Proxy: proxyServer(t, proxy), 131 | TLSClientConfig: tlsConfig(server), 132 | ForceAttemptHTTP2: true, 133 | }, 134 | } 135 | resp, err := client.Get(server.URL) 136 | require.NoError(t, err) 137 | defer resp.Body.Close() 138 | assert.Equal(t, 2, resp.ProtoMajor) 139 | assert.Equal(t, []string{"CONNECT to proxy", "GET to server"}, r.requests) 140 | } 141 | 142 | func newDirectProxy() ProxyHandler { 143 | return NewProxyHandler(nil, http.ProxyURL(nil), func(string) {}) 144 | } 145 | 146 | func newChildProxy(parent *httptest.Server) http.Handler { 147 | parentURL := &url.URL{Host: parent.Listener.Addr().String()} 148 | childProxy := NewProxyHandler(nil, getProxyFromContext, func(string) {}) 149 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 150 | ctx := context.WithValue(req.Context(), contextKeyProxy, parentURL) 151 | reqWithProxy := req.WithContext(ctx) 152 | childProxy.ServeHTTP(w, reqWithProxy) 153 | }) 154 | } 155 | 156 | func proxyServer(t *testing.T, proxy *httptest.Server) proxyFunc { 157 | u, err := url.Parse(proxy.URL) 158 | require.NoError(t, err) 159 | return http.ProxyURL(u) 160 | } 161 | 162 | func tlsConfig(server *httptest.Server) *tls.Config { 163 | cp := x509.NewCertPool() 164 | cp.AddCert(server.Certificate()) 165 | return &tls.Config{RootCAs: cp} 166 | } 167 | 168 | func TestGetOriginURLsNotProxied(t *testing.T) { 169 | mux := http.NewServeMux() 170 | mux.HandleFunc("/origin", func(w http.ResponseWriter, req *http.Request) { 171 | _, err := w.Write([]byte("Hello, client\n")) 172 | require.NoError(t, err) 173 | }) 174 | proxy := httptest.NewServer(newDirectProxy().WrapHandler(mux)) 175 | defer proxy.Close() 176 | client := &http.Client{Transport: &http.Transport{}} 177 | resp, err := client.Get(proxy.URL + "/origin") 178 | require.NoError(t, err) 179 | defer resp.Body.Close() 180 | assert.Equal(t, http.StatusOK, resp.StatusCode) 181 | body, err := io.ReadAll(resp.Body) 182 | require.NoError(t, err) 183 | assert.Equal(t, "Hello, client\n", string(body)) 184 | } 185 | 186 | type hopByHopTestServer struct { 187 | t *testing.T 188 | } 189 | 190 | func (s hopByHopTestServer) ServeHTTP(w http.ResponseWriter, req *http.Request) { 191 | assert.NotContains(s.t, req.Header, "Connection") 192 | assert.NotContains(s.t, req.Header, "Proxy-Authorization") 193 | assert.Contains(s.t, req.Header, "Authorization") 194 | assert.NotContains(s.t, req.Header, "X-Alpaca-Request") 195 | w.Header().Set("Connection", "X-Alpaca-Response") 196 | w.Header().Set("X-Alpaca-Response", "this should get dropped") 197 | w.WriteHeader(http.StatusOK) 198 | } 199 | 200 | func testHopByHopHeaders(t *testing.T, method, url string, proxy proxyFunc) { 201 | req, err := http.NewRequest(method, url, nil) 202 | require.NoError(t, err) 203 | req.Header.Set("Connection", "X-Alpaca-Request") 204 | req.Header.Set("Proxy-Authorization", "Basic bWFsb3J5YXJjaGVyOmd1ZXN0") 205 | req.Header.Set("Authorization", "Basic bmlrb2xhaWpha292Omd1ZXN0") 206 | req.Header.Set("X-Alpaca-Request", "this should get dropped") 207 | 208 | tr := &http.Transport{Proxy: proxy} 209 | resp, err := tr.RoundTrip(req) 210 | require.NoError(t, err) 211 | defer resp.Body.Close() 212 | 213 | require.Equal(t, http.StatusOK, resp.StatusCode) 214 | assert.NotContains(t, resp.Header, "Connection") 215 | assert.NotContains(t, resp.Header, "X-Alpaca-Response") 216 | } 217 | 218 | func TestHopByHopHeaders(t *testing.T) { 219 | server := httptest.NewServer(hopByHopTestServer{t}) 220 | defer server.Close() 221 | proxy := httptest.NewServer(newDirectProxy()) 222 | defer proxy.Close() 223 | testHopByHopHeaders(t, http.MethodGet, server.URL, proxyServer(t, proxy)) 224 | } 225 | 226 | func TestHopByHopHeadersForConnectRequest(t *testing.T) { 227 | parent := httptest.NewServer(hopByHopTestServer{t}) 228 | defer parent.Close() 229 | child := httptest.NewServer(newChildProxy(parent)) 230 | defer child.Close() 231 | testHopByHopHeaders(t, http.MethodConnect, parent.URL, proxyServer(t, child)) 232 | } 233 | 234 | func TestDeleteConnectionTokens(t *testing.T) { 235 | header := make(http.Header) 236 | header.Add("Connection", "close") 237 | header.Add("Connection", "x-alpaca-1, x-alpaca-2") 238 | header.Set("X-Alpaca-1", "this should get dropped") 239 | header.Set("X-Alpaca-2", "this should get dropped") 240 | header.Set("X-Alpaca-3", "this should NOT get dropped") 241 | deleteConnectionTokens(header) 242 | assert.NotContains(t, header, "X-Alpaca-1") 243 | assert.NotContains(t, header, "X-Alpaca-2") 244 | assert.Contains(t, header, "X-Alpaca-3") 245 | } 246 | 247 | func TestCloseFromOneSideResultsInEOFOnOtherSide(t *testing.T) { 248 | closeConnection := func(conn net.Conn) { 249 | conn.Close() 250 | } 251 | assertEOF := func(conn net.Conn) { 252 | _, err := bufio.NewReader(conn).Peek(1) 253 | assert.Equal(t, io.EOF, err) 254 | } 255 | testProxyTunnel(t, closeConnection, assertEOF) 256 | testProxyTunnel(t, assertEOF, closeConnection) 257 | } 258 | 259 | func testProxyTunnel(t *testing.T, onServer, onClient func(conn net.Conn)) { 260 | // Set up a Listener to act as a server, which we'll connect to via the proxy. 261 | server, err := net.Listen("tcp", "localhost:0") 262 | require.NoError(t, err) 263 | defer server.Close() 264 | proxy := httptest.NewServer(newDirectProxy()) 265 | defer proxy.Close() 266 | client, err := net.Dial("tcp", proxy.Listener.Addr().String()) 267 | require.NoError(t, err) 268 | defer client.Close() 269 | // The server just accepts a connection and calls the callback. 270 | done := make(chan struct{}) 271 | go func() { 272 | defer close(done) 273 | conn, err := server.Accept() 274 | require.NoError(t, err) 275 | onServer(conn) 276 | }() 277 | // Connect to the server via the proxy, using a CONNECT request. 278 | serverURL := url.URL{Host: server.Addr().String()} 279 | req, err := http.NewRequest(http.MethodConnect, serverURL.String(), nil) 280 | require.NoError(t, err) 281 | require.NoError(t, req.Write(client)) 282 | resp, err := http.ReadResponse(bufio.NewReader(client), req) 283 | require.NoError(t, err) 284 | require.Equal(t, http.StatusOK, resp.StatusCode) 285 | // Call the client callback, and then make sure that the server is done before finishing. 286 | onClient(client) 287 | <-done 288 | } 289 | 290 | func TestConnectResponseHeadersWithOneProxy(t *testing.T) { 291 | server, err := net.Listen("tcp", "localhost:0") 292 | require.NoError(t, err) 293 | defer server.Close() 294 | proxy := httptest.NewServer(newDirectProxy()) 295 | defer proxy.Close() 296 | client, err := net.Dial("tcp", proxy.Listener.Addr().String()) 297 | require.NoError(t, err) 298 | defer client.Close() 299 | testConnectResponseHeaders(t, server.Addr().String(), client) 300 | } 301 | 302 | func TestConnectResponseHeadersWithTwoProxies(t *testing.T) { 303 | server, err := net.Listen("tcp", "localhost:0") 304 | require.NoError(t, err) 305 | defer server.Close() 306 | parent := httptest.NewServer(newDirectProxy()) 307 | defer parent.Close() 308 | child := httptest.NewServer(newChildProxy(parent)) 309 | defer child.Close() 310 | client, err := net.Dial("tcp", child.Listener.Addr().String()) 311 | require.NoError(t, err) 312 | defer client.Close() 313 | testConnectResponseHeaders(t, server.Addr().String(), client) 314 | } 315 | 316 | func testConnectResponseHeaders(t *testing.T, server string, client net.Conn) { 317 | _, err := fmt.Fprintf(client, "CONNECT %s HTTP/1.1\nHost: %s\n\n", server, server) 318 | require.NoError(t, err) 319 | rd := bufio.NewReader(client) 320 | resp, err := http.ReadResponse(rd, nil) 321 | require.NoError(t, err) 322 | // A server MUST NOT send any Transfer-Encoding or Content-Length header fields in a 2xx 323 | // (Successful) response to CONNECT (see https://tools.ietf.org/html/rfc7231#section-4.3.6). 324 | assert.Equal(t, http.StatusOK, resp.StatusCode) 325 | assert.Empty(t, resp.TransferEncoding) 326 | assert.Equal(t, int64(-1), resp.ContentLength) 327 | } 328 | 329 | func TestConnectResponseHasCorrectNewlines(t *testing.T) { 330 | // See https://github.com/samuong/alpaca/issues/29 for some context behind this test. 331 | server, err := net.Listen("tcp", "localhost:0") 332 | require.NoError(t, err) 333 | defer server.Close() 334 | go func() { 335 | conn, err := server.Accept() 336 | require.NoError(t, err) 337 | conn.Close() 338 | }() 339 | proxy := httptest.NewServer(newDirectProxy()) 340 | defer proxy.Close() 341 | client, err := net.Dial("tcp", proxy.Listener.Addr().String()) 342 | require.NoError(t, err) 343 | defer client.Close() 344 | req := fmt.Sprintf("CONNECT %s HTTP/1.1\r\n\r\n", server.Addr().String()) 345 | _, err = client.Write([]byte(req)) 346 | require.NoError(t, err) 347 | buf, err := io.ReadAll(client) 348 | require.NoError(t, err) 349 | resp := string(buf) 350 | // "HTTP/1.1 defines the sequence CR LF as the end-of-line marker" 351 | // https://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2 352 | noCRLFs := strings.ReplaceAll(resp, "\r\n", "") 353 | assert.NotContains(t, noCRLFs, "\r", "response contains unmatched CR") 354 | assert.NotContains(t, noCRLFs, "\n", "response contains unmatched LF") 355 | } 356 | 357 | func TestConnectToNonExistentHost(t *testing.T) { 358 | proxy := httptest.NewServer(newDirectProxy()) 359 | defer proxy.Close() 360 | client := http.Client{Transport: &http.Transport{Proxy: proxyServer(t, proxy)}} 361 | _, err := client.Get("https://nonexistent.test") 362 | require.Error(t, err) 363 | } 364 | 365 | func TestTransportReturnsProxyConnectOpError(t *testing.T) { 366 | // ProxyHandler relies on net/http#Transport.RoundTrip to return a net.OpError with Op set 367 | // to "proxyconnect" in the event that the proxy is unreachable. This isn't actually 368 | // documented in the godocs, so test that this assumption is correct. 369 | tr := &http.Transport{Proxy: http.ProxyURL(&url.URL{Host: "nonexistent.test:80"})} 370 | req, err := http.NewRequest(http.MethodGet, "http://example.com", nil) 371 | require.NoError(t, err) 372 | _, err = tr.RoundTrip(req) 373 | require.Error(t, err) 374 | oe := err.(*net.OpError) 375 | assert.Equal(t, "proxyconnect", oe.Op) 376 | } 377 | -------------------------------------------------------------------------------- /pacrunner_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2020, 2021, 2023, 2024 The Alpaca Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "net" 19 | "net/url" 20 | "strings" 21 | "testing" 22 | "time" 23 | 24 | "github.com/robertkrimen/otto" 25 | "github.com/stretchr/testify/assert" 26 | "github.com/stretchr/testify/require" 27 | ) 28 | 29 | func TestDirect(t *testing.T) { 30 | var pr PACRunner 31 | pacjs := []byte(`function FindProxyForURL(url, host) { return "DIRECT" }`) 32 | require.NoError(t, pr.Update(pacjs)) 33 | proxy, err := pr.FindProxyForURL(url.URL{Scheme: "https", Host: "anz.com"}) 34 | require.NoError(t, err) 35 | assert.Equal(t, "DIRECT", proxy) 36 | } 37 | 38 | func TestFindProxyForURL(t *testing.T) { 39 | tests := []struct { 40 | name, input, expected string 41 | }{ 42 | {"NoScheme", "//alpaca.test", "https://alpaca.test/"}, 43 | {"HTTP", "http://alpaca.test/a?b=c#d", "http://alpaca.test/a?b=c#d"}, 44 | {"HTTPS", "https://alpaca.test/a?b=c#d", "https://alpaca.test/"}, 45 | {"WSS", "wss://alpaca.test/a?b=c#d", "wss://alpaca.test/"}, 46 | } 47 | for _, test := range tests { 48 | var pr PACRunner 49 | pacjs := []byte("function FindProxyForURL(url, host) { return url }") 50 | require.NoError(t, pr.Update(pacjs)) 51 | t.Run(test.name, func(t *testing.T) { 52 | u, err := url.Parse(test.input) 53 | require.NoError(t, err) 54 | proxy, err := pr.FindProxyForURL(*u) 55 | require.NoError(t, err) 56 | assert.Equal(t, test.expected, proxy) 57 | }) 58 | } 59 | } 60 | 61 | func TestIsPlainHostName(t *testing.T) { 62 | tests := []struct { 63 | host string 64 | expected bool 65 | }{ 66 | {"www", true}, 67 | {"anz.com", false}, 68 | } 69 | for _, test := range tests { 70 | t.Run(test.host, func(t *testing.T) { 71 | vm := otto.New() 72 | require.NoError(t, vm.Set("isPlainHostName", isPlainHostName)) 73 | value, err := vm.Call("isPlainHostName", nil, test.host) 74 | require.NoError(t, err) 75 | actual, err := value.ToBoolean() 76 | require.NoError(t, err) 77 | assert.Equal(t, test.expected, actual) 78 | }) 79 | } 80 | } 81 | 82 | func TestDnsDomainIs(t *testing.T) { 83 | tests := []struct { 84 | host, domain string 85 | expected bool 86 | }{ 87 | {"www.anz.com", ".anz.com", true}, 88 | {"www", ".anz.com", false}, 89 | {"notanz.com", ".anz.com", false}, 90 | {"notanz.com", "anz.com", true}, // https://crbug.com/299649 91 | } 92 | for _, test := range tests { 93 | t.Run(test.host+" "+test.domain, func(t *testing.T) { 94 | vm := otto.New() 95 | require.NoError(t, vm.Set("dnsDomainIs", dnsDomainIs)) 96 | value, err := vm.Call("dnsDomainIs", nil, test.host, test.domain) 97 | require.NoError(t, err) 98 | actual, err := value.ToBoolean() 99 | require.NoError(t, err) 100 | assert.Equal(t, test.expected, actual) 101 | }) 102 | } 103 | } 104 | 105 | func TestLocalHostOrDomainIs(t *testing.T) { 106 | tests := []struct { 107 | name string 108 | host string 109 | hostdom string 110 | expected bool 111 | }{ 112 | {"exact match", "www.mozilla.org", "www.mozilla.org", true}, 113 | {"hostname match", "www", "www.mozilla.org", true}, 114 | {"domain name mismatch", "www.google.com", "www.mozilla.org", false}, 115 | {"hostname mismatch", "home.mozilla.org", "www.mozilla.org", false}, 116 | } 117 | for _, test := range tests { 118 | t.Run(test.name, func(t *testing.T) { 119 | vm := otto.New() 120 | require.NoError(t, vm.Set("localHostOrDomainIs", localHostOrDomainIs)) 121 | value, err := vm.Call("localHostOrDomainIs", nil, test.host, test.hostdom) 122 | require.NoError(t, err) 123 | actual, err := value.ToBoolean() 124 | require.NoError(t, err) 125 | assert.Equal(t, test.expected, actual) 126 | }) 127 | } 128 | } 129 | 130 | func TestIsResolvable(t *testing.T) { 131 | tests := []struct { 132 | host string 133 | expected bool 134 | }{ 135 | {"localhost", true}, 136 | {"nonexistent.test", false}, 137 | } 138 | for _, test := range tests { 139 | t.Run(test.host, func(t *testing.T) { 140 | vm := otto.New() 141 | require.NoError(t, vm.Set("isResolvable", isResolvable)) 142 | value, err := vm.Call("isResolvable", nil, test.host) 143 | require.NoError(t, err) 144 | actual, err := value.ToBoolean() 145 | require.NoError(t, err) 146 | assert.Equal(t, test.expected, actual) 147 | }) 148 | } 149 | } 150 | 151 | func TestIsInNet(t *testing.T) { 152 | tests := []struct { 153 | host string 154 | pattern string 155 | mask string 156 | expected bool 157 | }{ 158 | {"localhost", "127.0.0.0", "255.0.0.0", true}, 159 | {"192.0.2.1", "192.0.2.0", "255.255.255.0", true}, 160 | {"192.0.3.1", "192.0.2.0", "255.255.255.0", false}, 161 | {"192.0.3.1", "192.0.2.0", "255.255.0.0", true}, 162 | {"192.0.3.1", "192.0.2.0", "255.255.255", false}, 163 | } 164 | for _, test := range tests { 165 | t.Run(test.host, func(t *testing.T) { 166 | vm := otto.New() 167 | require.NoError(t, vm.Set("isInNet", isInNet)) 168 | value, err := vm.Call("isInNet", nil, test.host, test.pattern, test.mask) 169 | require.NoError(t, err) 170 | actual, err := value.ToBoolean() 171 | require.NoError(t, err) 172 | assert.Equal(t, test.expected, actual) 173 | }) 174 | } 175 | } 176 | 177 | func TestDnsResolve(t *testing.T) { 178 | tests := []struct { 179 | host string 180 | expected string 181 | }{ 182 | {"localhost", "127.0.0.1"}, 183 | {"192.0.2.1", "192.0.2.1"}, 184 | } 185 | for _, test := range tests { 186 | t.Run(test.host, func(t *testing.T) { 187 | vm := otto.New() 188 | require.NoError(t, vm.Set("dnsResolve", dnsResolve)) 189 | value, err := vm.Call("dnsResolve", nil, test.host) 190 | require.NoError(t, err) 191 | actual, err := value.ToString() 192 | require.NoError(t, err) 193 | assert.Equal(t, test.expected, actual) 194 | }) 195 | } 196 | } 197 | 198 | func TestConvertAddr(t *testing.T) { 199 | tests := []struct { 200 | ipaddr string 201 | expected int64 202 | }{ 203 | {"104.16.41.2", 1745889538}, 204 | {"2001:db8::", 0}, 205 | {"www.anz.com", 0}, 206 | } 207 | for _, test := range tests { 208 | t.Run(test.ipaddr, func(t *testing.T) { 209 | vm := otto.New() 210 | require.NoError(t, vm.Set("convert_addr", convertAddr)) 211 | value, err := vm.Call("convert_addr", nil, test.ipaddr) 212 | require.NoError(t, err) 213 | actual, err := value.ToInteger() 214 | require.NoError(t, err) 215 | assert.Equal(t, test.expected, actual) 216 | }) 217 | } 218 | } 219 | 220 | func TestMyIpAddress(t *testing.T) { 221 | vm := otto.New() 222 | require.NoError(t, vm.Set("myIpAddress", myIpAddress)) 223 | value, err := vm.Call("myIpAddress", nil) 224 | require.NoError(t, err) 225 | output, err := value.ToString() 226 | require.NoError(t, err) 227 | // Check it's a valid IPv4 or IPv6 address. 228 | assert.NotNil(t, net.ParseIP(output)) 229 | // Check that it's our IP address. Technically there's a race condition here (since both 230 | // myIpAddress and this function will call net.InterfaceAddrs() separately), but this is 231 | // only going to cause flakiness if the network changes during the test, which is unlikely. 232 | addrs, err := net.InterfaceAddrs() 233 | require.NoError(t, err) 234 | for _, addr := range addrs { 235 | if strings.HasPrefix(addr.String(), output) { 236 | return 237 | } 238 | } 239 | t.Fail() 240 | } 241 | 242 | func TestDnsDomainLevels(t *testing.T) { 243 | tests := []struct { 244 | host string 245 | expected int64 246 | }{ 247 | {"www", 0}, 248 | {"mozilla.org", 1}, 249 | {"www.mozilla.org", 2}, 250 | } 251 | for _, test := range tests { 252 | t.Run(test.host, func(t *testing.T) { 253 | vm := otto.New() 254 | require.NoError(t, vm.Set("dnsDomainLevels", dnsDomainLevels)) 255 | value, err := vm.Call("dnsDomainLevels", nil, test.host) 256 | require.NoError(t, err) 257 | actual, err := value.ToInteger() 258 | require.NoError(t, err) 259 | assert.Equal(t, test.expected, actual) 260 | }) 261 | } 262 | } 263 | 264 | func TestShExpMatch(t *testing.T) { 265 | tests := []struct { 266 | str, shexp string 267 | expected bool 268 | }{ 269 | {"http://anz.com/a/b/c.html", "*/b/*", true}, 270 | {"http://anz.com/d/e/f.html", "*/b/*", false}, 271 | } 272 | for _, test := range tests { 273 | t.Run(test.str+" "+test.shexp, func(t *testing.T) { 274 | vm := otto.New() 275 | require.NoError(t, vm.Set("shExpMatch", shExpMatch)) 276 | value, err := vm.Call("shExpMatch", nil, test.str, test.shexp) 277 | require.NoError(t, err) 278 | actual, err := value.ToBoolean() 279 | require.NoError(t, err) 280 | assert.Equal(t, test.expected, actual) 281 | }) 282 | } 283 | } 284 | 285 | func TestWeekdayRange(t *testing.T) { 286 | tests := []struct { 287 | name string 288 | args []interface{} 289 | expectations string 290 | }{ 291 | {"M-F AEST", []interface{}{"MON", "FRI"}, "NYYYYYN"}, 292 | {"M-F UTC", []interface{}{"MON", "FRI", "GMT"}, "NNYYYYY"}, 293 | {"SAT AEST", []interface{}{"SAT"}, "NNNNNNY"}, 294 | {"SAT UTC", []interface{}{"SAT", "GMT"}, "YNNNNNN"}, 295 | {"F&M ONLY", []interface{}{"FRI", "MON"}, "NYNNNYN"}, 296 | } 297 | 298 | // The reference time is 10 hours ahead of UTC, so 5am is 7pm on the previous day in UTC. 299 | sunday, err := time.Parse(time.RFC1123Z, "Sun, 30 Jun 2019 05:00:00 +1000") 300 | require.NoError(t, err) 301 | weekdays := []struct { 302 | name string 303 | t time.Time 304 | }{ 305 | {"SUN", sunday}, 306 | {"MON", sunday.Add(1 * 24 * time.Hour)}, 307 | {"TUE", sunday.Add(2 * 24 * time.Hour)}, 308 | {"WED", sunday.Add(3 * 24 * time.Hour)}, 309 | {"THU", sunday.Add(4 * 24 * time.Hour)}, 310 | {"FRI", sunday.Add(5 * 24 * time.Hour)}, 311 | {"SAT", sunday.Add(6 * 24 * time.Hour)}, 312 | } 313 | 314 | for _, test := range tests { 315 | for i, weekday := range weekdays { 316 | t.Run(test.name+" "+weekday.name, func(t *testing.T) { 317 | vm := otto.New() 318 | f := func(fc otto.FunctionCall) otto.Value { 319 | return weekdayRange(fc, weekday.t) 320 | } 321 | require.NoError(t, vm.Set("weekdayRange", f)) 322 | value, err := vm.Call("weekdayRange", nil, test.args...) 323 | require.NoError(t, err) 324 | actual, err := value.ToBoolean() 325 | require.NoError(t, err) 326 | expected := test.expectations[i] == 'Y' 327 | assert.Equal(t, expected, actual) 328 | }) 329 | } 330 | } 331 | } 332 | 333 | func TestDateRange(t *testing.T) { 334 | tests := []struct { 335 | name string 336 | args []interface{} 337 | expectations map[string]bool 338 | }{ 339 | { 340 | "returns true on the first day of each month, local timezone", 341 | []interface{}{1}, 342 | map[string]bool{ 343 | "2019-06-30": false, 344 | "2019-07-01": true, 345 | "2019-07-02": false, 346 | "2019-08-01": true, 347 | }, 348 | }, { 349 | // All of these dates are tested with a time of 5am in AEST, which is 10 350 | // hours ahead of UTC. So 5am in AEST is 7pm on the previous day in UTC. 351 | "returns true on the first day of each month, GMT timezone", 352 | []interface{}{1, "GMT"}, 353 | map[string]bool{ 354 | "2019-07-01": false, 355 | "2019-07-02": true, 356 | "2019-07-03": false, 357 | "2019-08-02": true, 358 | }, 359 | }, { 360 | "returns true on the first half of each month", 361 | []interface{}{1, 15}, 362 | map[string]bool{ 363 | "2019-06-30": false, 364 | "2019-07-01": true, 365 | "2019-07-02": true, 366 | "2019-07-15": true, 367 | "2019-07-16": false, 368 | }, 369 | }, { 370 | "returns true on 24th of December each year", 371 | []interface{}{24, "DEC"}, 372 | map[string]bool{ 373 | "2019-12-23": false, 374 | "2019-12-24": true, 375 | "2019-12-26": false, 376 | "2020-12-24": true, 377 | }, 378 | }, { 379 | "returns true on the first quarter of the year", 380 | []interface{}{"JAN", "MAR"}, 381 | map[string]bool{ 382 | "2018-12-31": false, 383 | "2019-01-01": true, 384 | "2019-01-02": true, 385 | "2019-03-31": true, 386 | "2019-04-01": false, 387 | "2020-03-31": true, 388 | }, 389 | }, { 390 | "returns true from June 1st until August 15th, each year", 391 | []interface{}{1, "JUN", 15, "AUG"}, 392 | map[string]bool{ 393 | "2019-05-31": false, 394 | "2019-06-01": true, 395 | "2019-06-02": true, 396 | "2019-08-15": true, 397 | "2019-08-16": false, 398 | "2020-08-15": true, 399 | }, 400 | }, { 401 | "returns true from June 1st, 1995, until August 15th, same year", 402 | []interface{}{1, "JUN", 1995, 15, "AUG", 1995}, 403 | map[string]bool{ 404 | "1995-05-31": false, 405 | "1995-06-01": true, 406 | "1995-06-02": true, 407 | "1995-08-15": true, 408 | "1995-08-16": false, 409 | "1996-08-15": false, 410 | }, 411 | }, { 412 | "returns true from October 1995 until March 1996", 413 | []interface{}{"OCT", 1995, "MAR", 1996}, 414 | map[string]bool{ 415 | "1995-09-30": false, 416 | "1995-10-01": true, 417 | "1995-10-02": true, 418 | "1996-01-01": true, 419 | "1996-03-31": true, 420 | "1996-04-01": false, 421 | "1997-01-01": false, 422 | }, 423 | }, { 424 | "returns true during the entire year of 1995", 425 | []interface{}{1995}, 426 | map[string]bool{ 427 | "1994-12-31": false, 428 | "1995-01-01": true, 429 | "1995-04-19": true, 430 | "1995-12-31": true, 431 | "1996-01-01": false, 432 | }, 433 | }, { 434 | "returns true from beginning of year 1995 until the end of year 1997", 435 | []interface{}{1995, 1997}, 436 | map[string]bool{ 437 | "1994-12-31": false, 438 | "1995-01-01": true, 439 | "1996-04-19": true, 440 | "1997-12-31": true, 441 | "1998-01-01": false, 442 | }, 443 | }, 444 | } 445 | 446 | check := func(t *testing.T, args []interface{}, date string, expected bool) { 447 | vm := otto.New() 448 | now, err := time.Parse(time.RFC3339, date+"T05:00:00+10:00") 449 | require.NoError(t, err) 450 | f := func(fc otto.FunctionCall) otto.Value { return dateRange(fc, now) } 451 | require.NoError(t, vm.Set("dateRange", f)) 452 | value, err := vm.Call("dateRange", nil, args...) 453 | require.NoError(t, err) 454 | actual, err := value.ToBoolean() 455 | require.NoError(t, err) 456 | assert.Equal(t, expected, actual) 457 | } 458 | 459 | for _, test := range tests { 460 | for date, expected := range test.expectations { 461 | t.Run(test.name+" "+date, func(t *testing.T) { 462 | check(t, test.args, date, expected) 463 | }) 464 | } 465 | } 466 | } 467 | 468 | func TestTimeRange(t *testing.T) { 469 | tests := []struct { 470 | name string 471 | args []interface{} 472 | expectations map[string]bool 473 | }{ 474 | { 475 | "returns true from noon to 1pm", 476 | []interface{}{12}, 477 | map[string]bool{ 478 | "11:59:59": false, 479 | "12:00:00": true, 480 | "12:00:01": true, 481 | "12:59:59": true, 482 | "13:00:00": false, 483 | }, 484 | }, { 485 | "returns true from noon to 1pm", 486 | []interface{}{12, 13}, 487 | map[string]bool{ 488 | "11:59:59": false, 489 | "12:00:00": true, 490 | "12:00:01": true, 491 | "12:59:59": true, 492 | "13:00:00": false, 493 | }, 494 | }, { 495 | // Local time (AEST) is 10 hours ahead of UTC, so we expect timeRange to 496 | // return true from 10pm to 11pm AEST. 497 | "true from noon to 1pm, in GMT timezone", 498 | []interface{}{12, "GMT"}, 499 | map[string]bool{ 500 | "21:59:59": false, 501 | "22:00:00": true, 502 | "22:00:01": true, 503 | "22:59:59": true, 504 | "23:00:00": false, 505 | }, 506 | }, { 507 | "returns true from 9am to 5pm", 508 | []interface{}{9, 17}, 509 | map[string]bool{ 510 | "08:59:59": false, 511 | "09:00:00": true, 512 | "09:00:01": true, 513 | "16:59:59": true, 514 | "17:00:00": false, 515 | }, 516 | }, { 517 | "returns true from 8:30am to 5:00pm", 518 | []interface{}{8, 30, 17, 00}, 519 | map[string]bool{ 520 | "08:29:59": false, 521 | "08:30:00": true, 522 | "08:30:01": true, 523 | "16:59:59": true, 524 | "17:00:00": false, 525 | }, 526 | }, { 527 | "returns true between midnight and 30 seconds past midnight", 528 | []interface{}{0, 0, 0, 0, 0, 30}, 529 | map[string]bool{ 530 | "00:00:00": true, 531 | "00:00:01": true, 532 | "00:00:29": true, 533 | "00:00:30": false, 534 | "23:59:59": false, 535 | }, 536 | }, 537 | } 538 | 539 | check := func(t *testing.T, args []interface{}, mocktime string, expected bool) { 540 | vm := otto.New() 541 | now, err := time.Parse(time.RFC3339, "2019-07-01T"+mocktime+"+10:00") 542 | require.NoError(t, err) 543 | f := func(fc otto.FunctionCall) otto.Value { return timeRange(fc, now) } 544 | require.NoError(t, vm.Set("timeRange", f)) 545 | value, err := vm.Call("timeRange", nil, args...) 546 | require.NoError(t, err) 547 | actual, err := value.ToBoolean() 548 | require.NoError(t, err) 549 | assert.Equal(t, expected, actual) 550 | } 551 | 552 | for _, test := range tests { 553 | for mocktime, expected := range test.expectations { 554 | t.Run(test.name+" "+mocktime, func(t *testing.T) { 555 | check(t, test.args, mocktime, expected) 556 | }) 557 | } 558 | } 559 | } 560 | --------------------------------------------------------------------------------