First commit
This commit is contained in:
commit
e9249b05f4
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
pip-proxy
|
19
LICENSE.txt
Normal file
19
LICENSE.txt
Normal file
@ -0,0 +1,19 @@
|
||||
Copyright (c) 2025 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
20
README.md
Normal file
20
README.md
Normal file
@ -0,0 +1,20 @@
|
||||
# pip-proxy
|
||||
|
||||
A proxy server to set as your PIP mirror and limit what versions of a package is available, based on python version and or individual package versions.
|
||||
|
||||
## Using the proxy as an mirror
|
||||
|
||||
In either `/etc/pip.conf` or `~/pip/pip.conf`:
|
||||
```ini
|
||||
[global]
|
||||
index-url=http://192.168.122.1:8080/simple
|
||||
trusted-host=192.168.122.1
|
||||
```
|
||||
|
||||
In `/root/.pydistutils.cfg`:
|
||||
```ini
|
||||
[easy_install]
|
||||
index-url=http://192.168.122.1:8080/simple
|
||||
```
|
||||
|
||||
To configure the constraints, edit config.yaml
|
4
config.yaml
Normal file
4
config.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
max_python_version: 3.6
|
||||
package_version_limits:
|
||||
xmlsec: 1.3.3
|
9
go.mod
Normal file
9
go.mod
Normal file
@ -0,0 +1,9 @@
|
||||
module github.com/grmrgecko/pip-proxy
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require github.com/hashicorp/go-version v1.7.0
|
||||
|
||||
require github.com/gobwas/glob v0.2.3
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
7
go.sum
Normal file
7
go.sum
Normal file
@ -0,0 +1,7 @@
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
252
main.go
Normal file
252
main.go
Normal file
@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gobwas/glob"
|
||||
"github.com/hashicorp/go-version"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config file structure.
|
||||
type Config struct {
|
||||
HttpPort uint `yaml:"hddp_port"`
|
||||
HttpBind string `yaml:"http_bind"`
|
||||
MaxPythonVersion string `yaml:"max_python_version"`
|
||||
PackageVersionLimits map[string]string `yaml:"package_version_limits"`
|
||||
}
|
||||
|
||||
var config *Config
|
||||
|
||||
// Handle http proxy request.
|
||||
func handleRequest(rw http.ResponseWriter, req *http.Request) {
|
||||
// Replace the scheme and host to pypi.org.
|
||||
url := req.URL
|
||||
url.Scheme = "https"
|
||||
url.Host = "pypi.org"
|
||||
|
||||
// Create a new request based on the original
|
||||
outreq, err := http.NewRequest(req.Method, url.String(), req.Body)
|
||||
if err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Copy headers from original request, and correct the host header.
|
||||
outreq.Header = req.Header
|
||||
outreq.Header.Set("Host", url.Host)
|
||||
|
||||
// Make sure we close the request body after the function call is done.
|
||||
if outreq.Body != nil {
|
||||
defer outreq.Body.Close()
|
||||
}
|
||||
|
||||
// Send the request.
|
||||
res, err := http.DefaultTransport.RoundTrip(outreq)
|
||||
if err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
// Copy headers received to requester.
|
||||
for k, vv := range res.Header {
|
||||
for _, v := range vv {
|
||||
rw.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Log the request.
|
||||
log.Println(req.Method, url, res.StatusCode)
|
||||
|
||||
// Verify this is `/simple/package/` request.
|
||||
pathS := strings.Split(req.URL.Path, "/")
|
||||
if len(pathS) == 4 {
|
||||
// Get the package being requested, and check if there is a version constraint on it.
|
||||
pkg := pathS[2]
|
||||
pkgVerMax, hasVerConstraint := config.PackageVersionLimits[pkg]
|
||||
var pkgVer *version.Version
|
||||
if hasVerConstraint {
|
||||
pkgVer, _ = version.NewVersion(pkgVerMax)
|
||||
}
|
||||
|
||||
// Buffer to store the modified response.
|
||||
bodyBuff := new(bytes.Buffer)
|
||||
|
||||
// Determine body, gzip encoded or plain text.
|
||||
var bodyReader io.Reader = res.Body
|
||||
gzipEncoded := res.Header.Get("Content-Encoding") == "gzip"
|
||||
if gzipEncoded {
|
||||
bodyReader, err = gzip.NewReader(res.Body)
|
||||
}
|
||||
|
||||
// Setup scanner and matching variables.
|
||||
scanner := bufio.NewScanner(bodyReader)
|
||||
constraintsRx := regexp.MustCompile(`data-requires-python="([^"]+)"`)
|
||||
pkgVersionRx := regexp.MustCompile(`<a[^>]+>[^<]+-([0-9]+.[0-9]+.[0-9]+)[-.][^<]+</a>`)
|
||||
pyVersion, _ := version.NewVersion(config.MaxPythonVersion)
|
||||
|
||||
// Scan each line, and apply version constraints.
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Check if this has the python version constraints.
|
||||
matches := constraintsRx.FindAllStringSubmatch(line, 1)
|
||||
if len(matches) == 1 {
|
||||
// Compare and skip if the constraints are not matched.
|
||||
rules := strings.Split(html.UnescapeString(matches[0][1]), ",")
|
||||
versionMatch := true
|
||||
for _, rule := range rules {
|
||||
var cmpB strings.Builder
|
||||
var verB strings.Builder
|
||||
wildCard := false
|
||||
for _, b := range rule {
|
||||
if b == '<' || b == '>' || b == '=' || b == '!' || b == '~' {
|
||||
cmpB.WriteRune(b)
|
||||
} else if b == ' ' {
|
||||
continue
|
||||
} else {
|
||||
if b == '*' {
|
||||
wildCard = true
|
||||
}
|
||||
verB.WriteRune(b)
|
||||
}
|
||||
}
|
||||
var g glob.Glob
|
||||
var ver *version.Version
|
||||
cmp := cmpB.String()
|
||||
if wildCard && cmp == "==" || cmp == "!=" {
|
||||
g = glob.MustCompile(verB.String())
|
||||
} else {
|
||||
verS := verB.String()
|
||||
if wildCard {
|
||||
verS = strings.ReplaceAll(verS, "*", "0")
|
||||
}
|
||||
ver, err = version.NewVersion(verS)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
switch cmp {
|
||||
case "==":
|
||||
if wildCard {
|
||||
if !g.Match(config.MaxPythonVersion) {
|
||||
versionMatch = false
|
||||
}
|
||||
} else if !pyVersion.Equal(ver) {
|
||||
versionMatch = false
|
||||
}
|
||||
case "!=":
|
||||
if wildCard {
|
||||
if g.Match(config.MaxPythonVersion) {
|
||||
versionMatch = false
|
||||
}
|
||||
} else if pyVersion.Equal(ver) {
|
||||
versionMatch = false
|
||||
}
|
||||
case "=~":
|
||||
g = glob.MustCompile(fmt.Sprintf("%s.*", config.MaxPythonVersion))
|
||||
if !g.Match(ver.String()) {
|
||||
versionMatch = false
|
||||
}
|
||||
case ">=":
|
||||
if !pyVersion.GreaterThanOrEqual(ver) {
|
||||
versionMatch = false
|
||||
}
|
||||
case ">":
|
||||
if !pyVersion.GreaterThan(ver) {
|
||||
versionMatch = false
|
||||
}
|
||||
case "<=":
|
||||
if !pyVersion.LessThanOrEqual(ver) {
|
||||
versionMatch = false
|
||||
}
|
||||
case "<":
|
||||
if !pyVersion.LessThan(ver) {
|
||||
versionMatch = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !versionMatch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If there is a package version constraint, compare the package version and skip if not met.
|
||||
if hasVerConstraint {
|
||||
matches = pkgVersionRx.FindAllStringSubmatch(line, 1)
|
||||
if len(matches) == 1 {
|
||||
ver, err := version.NewVersion(matches[0][1])
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
} else {
|
||||
if pkgVer.LessThan(ver) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write the read line.
|
||||
fmt.Fprintln(bodyBuff, line)
|
||||
}
|
||||
|
||||
// Get the modified data and compress with gzip if needed.
|
||||
bodyBytes := bodyBuff.Bytes()
|
||||
if gzipEncoded {
|
||||
bodyBuff = new(bytes.Buffer)
|
||||
gzipWriter := gzip.NewWriter(bodyBuff)
|
||||
gzipWriter.Write(bodyBytes)
|
||||
gzipWriter.Close()
|
||||
bodyBytes = bodyBuff.Bytes()
|
||||
}
|
||||
|
||||
// Update the content length.
|
||||
rw.Header().Set("Content-Length", strconv.Itoa(len(bodyBytes)))
|
||||
|
||||
// Send headers.
|
||||
rw.WriteHeader(res.StatusCode)
|
||||
|
||||
// Send the modified body.
|
||||
rw.Write(bodyBytes)
|
||||
} else {
|
||||
// Just copy the body as there is nothing to limit here.
|
||||
rw.WriteHeader(res.StatusCode)
|
||||
io.Copy(rw, res.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Read the yaml configuration.
|
||||
yamlD, err := os.ReadFile("config.yaml")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
config = &Config{
|
||||
HttpPort: 8080,
|
||||
}
|
||||
err = yaml.Unmarshal(yamlD, config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Start the server.
|
||||
bindAddr := fmt.Sprintf("%s:%d", config.HttpBind, config.HttpPort)
|
||||
log.Println("Starting proxy server on", bindAddr)
|
||||
err = http.ListenAndServe(bindAddr, http.HandlerFunc(handleRequest))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user