package cluster import ( "context" "errors" "fmt" "net" "strconv" "strings" ) type RepoTunnelOptions struct { Gateway string GatewayIP string Table int Priority int PackageManager string Command string KeepEnabled bool NoRule bool } type RepoTunnelState struct { Enabled bool `json:"enabled"` Proxy string `json:"proxy,omitempty"` Source string `json:"source,omitempty"` } func (s *Service) RepoTunnelEnable(ctx context.Context, selector string, opts RepoTunnelOptions) (string, error) { script, err := repoTunnelEnableScript(opts) if err != nil { return "", err } out, err := s.RunRemoteShell(ctx, selector, script) if err != nil { return "", err } gw, err := parseRepoTunnelGateway(opts.Gateway) if err == nil { _ = s.updateRepoTunnelState(selector, RepoTunnelState{ Enabled: true, Proxy: gw.proxyURL, Source: strings.ToLower(strings.TrimSpace(opts.PackageManager)), }) } return out, nil } func (s *Service) RepoTunnelDisable(ctx context.Context, selector string, opts RepoTunnelOptions) (string, error) { script, err := repoTunnelDisableScript(opts) if err != nil { return "", err } out, err := s.RunRemoteShell(ctx, selector, script) if err != nil { return "", err } _ = s.updateRepoTunnelState(selector, RepoTunnelState{}) return out, nil } func (s *Service) RepoTunnelState(ctx context.Context, selector string) (RepoTunnelState, error) { out, err := s.RunRemoteShell(ctx, selector, repoTunnelDetectScript()) if err != nil { return RepoTunnelState{}, err } state := RepoTunnelState{} for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "proxy="): state.Proxy = strings.TrimSpace(strings.TrimPrefix(line, "proxy=")) case strings.HasPrefix(line, "source="): state.Source = strings.TrimSpace(strings.TrimPrefix(line, "source=")) } } state.Enabled = state.Proxy != "" return state, nil } func (s *Service) updateRepoTunnelState(selector string, state RepoTunnelState) error { reg, err := s.store.Load() if err != nil { return err } c, idx, err := findCluster(reg, selector) if err != nil { return err } c.RepoTunnel = state c.UpdatedAt = s.now().UTC() reg.Clusters[idx] = c return s.store.Save(reg) } func (s *Service) RepoTunnelStatus(ctx context.Context, selector string) (string, error) { return s.RunRemoteShell(ctx, selector, repoTunnelStatusScript()) } func (s *Service) RepoTunnelInstall(ctx context.Context, selector string, opts RepoTunnelOptions) (string, error) { if strings.TrimSpace(opts.Command) == "" { return "", errors.New("install command is required") } enableScript, err := repoTunnelEnableScript(opts) if err != nil { return "", err } disableScript, err := repoTunnelDisableScript(opts) if err != nil { return "", err } body := enableScript + "\n" + strings.TrimSpace(opts.Command) + "\n" if !opts.KeepEnabled { body = enableScript + "\ncleanup_pxmon_repo_tunnel() {\n" + disableScript + "\n}\ntrap cleanup_pxmon_repo_tunnel EXIT\n" + strings.TrimSpace(opts.Command) + "\n" } return s.RunRemoteShell(ctx, selector, body) } func RepoTunnelGatewayScript(port int, allowCIDRs []string) (string, error) { if port == 0 { port = 3128 } if port < 1 || port > 65535 { return "", errors.New("--port must be in range 1..65535") } if len(allowCIDRs) == 0 { return "", errors.New("at least one --allow CIDR/IP is required") } aclParts := make([]string, 0, len(allowCIDRs)) for _, raw := range allowCIDRs { v := strings.TrimSpace(raw) if v == "" { continue } if !validSquidSrcACL(v) { return "", fmt.Errorf("invalid --allow %q; use an IP or CIDR without spaces", raw) } aclParts = append(aclParts, v) } if len(aclParts) == 0 { return "", errors.New("at least one --allow CIDR/IP is required") } return fmt.Sprintf(`set -eu if command -v dnf >/dev/null 2>&1; then dnf install -y squid elif command -v yum >/dev/null 2>&1; then yum install -y squid elif command -v apt-get >/dev/null 2>&1; then apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y squid else echo "no supported package manager found for squid install" >&2 exit 1 fi conf=/etc/squid/squid.conf cp -a "$conf" "$conf.pxmon-bak.$(date +%%Y%%m%%d%%H%%M%%S)" awk ' /# BEGIN PXMON REPO TUNNEL/ {skip=1; next} /# END PXMON REPO TUNNEL/ {skip=0; next} !skip {print} ' "$conf" > "$conf.tmp" mv "$conf.tmp" "$conf" block=$(mktemp) { echo "# BEGIN PXMON REPO TUNNEL" if ! grep -Eq "^http_port[[:space:]]+([^[:space:]]+:)?%d\b" "$conf"; then echo "http_port %d" fi echo "acl pxmon_repo_tunnel src %s" echo "http_access allow pxmon_repo_tunnel" echo "# END PXMON REPO TUNNEL" } > "$block" if grep -q "^http_access deny all" "$conf"; then awk -v block="$block" ' BEGIN {while ((getline line < block) > 0) b = b line "\n"; close(block); inserted=0} /^http_access deny all/ && !inserted {printf "%%s", b; inserted=1} {print} END {if (!inserted) printf "%%s", b} ' "$conf" > "$conf.tmp" mv "$conf.tmp" "$conf" else cat "$block" >> "$conf" fi rm -f "$block" systemctl enable --now squid systemctl restart squid if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then firewall-cmd --add-port=%d/tcp --permanent firewall-cmd --reload fi echo "pxmon repo gateway ready on port %d" `, port, port, strings.Join(aclParts, " "), port, port), nil } func validSquidSrcACL(v string) bool { for _, r := range v { if r >= 'a' && r <= 'z' { continue } if r >= 'A' && r <= 'Z' { continue } if r >= '0' && r <= '9' { continue } switch r { case '.', ':', '/', '_', '-': continue default: return false } } return v != "" } func repoTunnelEnableScript(opts RepoTunnelOptions) (string, error) { gw, err := parseRepoTunnelGateway(opts.Gateway) if err != nil { return "", err } if !opts.NoRule && opts.Table <= 0 { return "", errors.New("--table is required unless --no-rule is used") } manager := strings.ToLower(strings.TrimSpace(opts.PackageManager)) if manager == "" { manager = "auto" } if manager != "auto" && manager != "apt" && manager != "dnf" && manager != "yum" { return "", fmt.Errorf("unsupported --manager %q", opts.PackageManager) } ruleLine := "" if !opts.NoRule { addCmd := fmt.Sprintf("ip rule add to \"$PXMON_GATEWAY_IP/32\" table %d", opts.Table) if opts.Priority > 0 { addCmd = fmt.Sprintf("ip rule add priority %d to \"$PXMON_GATEWAY_IP/32\" table %d", opts.Priority, opts.Table) } ruleLine = fmt.Sprintf(` if ! ip rule show | grep -Eq "to[[:space:]]+$PXMON_GATEWAY_IP(/32)?[[:space:]].*lookup[[:space:]]+%d\b"; then %s fi`, opts.Table, addCmd) } return fmt.Sprintf(`set -eu PXMON_GATEWAY_HOST=%s PXMON_GATEWAY_IP=%s PXMON_PROXY_URL=%s PXMON_MANAGER=%s if [ -z "$PXMON_GATEWAY_IP" ]; then PXMON_GATEWAY_IP=$(getent ahostsv4 "$PXMON_GATEWAY_HOST" | awk '{print $1; exit}') fi if [ -z "$PXMON_GATEWAY_IP" ]; then echo "cannot resolve repo gateway: $PXMON_GATEWAY_HOST" >&2 exit 1 fi %s pxmon_repo_manager="$PXMON_MANAGER" if [ "$pxmon_repo_manager" = "auto" ]; then if command -v apt-get >/dev/null 2>&1; then pxmon_repo_manager=apt elif command -v dnf >/dev/null 2>&1; then pxmon_repo_manager=dnf elif command -v yum >/dev/null 2>&1; then pxmon_repo_manager=yum else echo "no supported package manager found" >&2; exit 1 fi fi case "$pxmon_repo_manager" in apt) mkdir -p /etc/apt/apt.conf.d cat > /etc/apt/apt.conf.d/99-pxmon-repo-tunnel < "$conf.tmp" mv "$conf.tmp" "$conf" { echo "# BEGIN PXMON REPO TUNNEL" echo "proxy=$PXMON_PROXY_URL" echo "# END PXMON REPO TUNNEL" } >> "$conf" ;; esac echo "pxmon repo tunnel enabled: proxy=$PXMON_PROXY_URL gateway_ip=$PXMON_GATEWAY_IP manager=$pxmon_repo_manager" `, shellQuote(gw.host), shellQuote(strings.TrimSpace(opts.GatewayIP)), shellQuote(gw.proxyURL), shellQuote(manager), ruleLine), nil } func repoTunnelDisableScript(opts RepoTunnelOptions) (string, error) { gw, err := parseRepoTunnelGateway(opts.Gateway) if err != nil && !opts.NoRule { return "", err } ruleLine := "" if !opts.NoRule { if opts.Table <= 0 { return "", errors.New("--table is required unless --no-rule is used") } ruleLine = fmt.Sprintf(` PXMON_GATEWAY_HOST=%s PXMON_GATEWAY_IP=%s if [ -z "$PXMON_GATEWAY_IP" ]; then PXMON_GATEWAY_IP=$(getent ahostsv4 "$PXMON_GATEWAY_HOST" | awk '{print $1; exit}') fi if [ -n "$PXMON_GATEWAY_IP" ]; then while ip rule show | grep -Eq "to[[:space:]]+$PXMON_GATEWAY_IP(/32)?[[:space:]].*lookup[[:space:]]+%d\b"; do ip rule del to "$PXMON_GATEWAY_IP/32" table %d 2>/dev/null || break done fi`, shellQuote(gw.host), shellQuote(strings.TrimSpace(opts.GatewayIP)), opts.Table, opts.Table) } return fmt.Sprintf(`set -eu rm -f /etc/apt/apt.conf.d/99-pxmon-repo-tunnel for conf in /etc/dnf/dnf.conf /etc/yum.conf; do if [ -f "$conf" ]; then awk ' /# BEGIN PXMON REPO TUNNEL/ {skip=1; next} /# END PXMON REPO TUNNEL/ {skip=0; next} !skip {print} ' "$conf" > "$conf.tmp" mv "$conf.tmp" "$conf" fi done %s echo "pxmon repo tunnel disabled" `, ruleLine), nil } func repoTunnelStatusScript() string { return `set -eu echo "== ip rules ==" ip rule show | grep -E "lookup|table" || true echo echo "== apt proxy ==" [ -f /etc/apt/apt.conf.d/99-pxmon-repo-tunnel ] && cat /etc/apt/apt.conf.d/99-pxmon-repo-tunnel || echo "(none)" echo echo "== dnf/yum proxy ==" for conf in /etc/dnf/dnf.conf /etc/yum.conf; do [ -f "$conf" ] || continue echo "-- $conf" awk '/# BEGIN PXMON REPO TUNNEL/,/# END PXMON REPO TUNNEL/ {print}' "$conf" done` } func repoTunnelDetectScript() string { return `set -eu if [ -f /etc/apt/apt.conf.d/99-pxmon-repo-tunnel ]; then proxy=$(sed -n 's/.*Proxy[[:space:]]*"\([^"]*\)".*/\1/p' /etc/apt/apt.conf.d/99-pxmon-repo-tunnel | head -1) [ -n "$proxy" ] && printf 'proxy=%s\nsource=apt\n' "$proxy" && exit 0 fi for conf in /etc/dnf/dnf.conf /etc/yum.conf; do [ -f "$conf" ] || continue proxy=$(awk ' /# BEGIN PXMON REPO TUNNEL/ {inside=1; next} /# END PXMON REPO TUNNEL/ {inside=0; next} inside && /^proxy[[:space:]]*=/ { sub(/^[^=]*=/, "") gsub(/^[[:space:]]+|[[:space:]]+$/, "") print exit } ' "$conf") [ -n "$proxy" ] && printf 'proxy=%s\nsource=%s\n' "$proxy" "$conf" && exit 0 done exit 0` } type repoTunnelGateway struct { host string port int proxyURL string } func parseRepoTunnelGateway(raw string) (repoTunnelGateway, error) { v := strings.TrimSpace(raw) if v == "" { return repoTunnelGateway{}, errors.New("--gateway is required") } if strings.HasPrefix(v, "http://") { v = strings.TrimPrefix(v, "http://") } if strings.HasPrefix(v, "https://") { return repoTunnelGateway{}, errors.New("--gateway must be an http proxy endpoint, not https") } host, portRaw, err := net.SplitHostPort(v) if err != nil { if strings.Count(v, ":") > 1 { return repoTunnelGateway{}, fmt.Errorf("invalid --gateway %q; use host:port or [ipv6]:port", raw) } host = v portRaw = "3128" } host = strings.Trim(host, "[]") if strings.TrimSpace(host) == "" { return repoTunnelGateway{}, errors.New("--gateway host is empty") } port, err := strconv.Atoi(portRaw) if err != nil || port < 1 || port > 65535 { return repoTunnelGateway{}, fmt.Errorf("invalid --gateway port %q", portRaw) } return repoTunnelGateway{ host: host, port: port, proxyURL: "http://" + net.JoinHostPort(host, strconv.Itoa(port)), }, nil }