done
This commit is contained in:
218
internal/config/manager.go
Normal file
218
internal/config/manager.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gostacks.org/iwasforcedtobehere/fastestmirror/internal/distro"
|
||||
)
|
||||
|
||||
// ConfigManager handles backup and modification of distribution config files
|
||||
type ConfigManager struct {
|
||||
DistroInfo *distro.DistroInfo
|
||||
BackupDir string
|
||||
}
|
||||
|
||||
// NewConfigManager creates a new configuration manager
|
||||
func NewConfigManager(distroInfo *distro.DistroInfo) *ConfigManager {
|
||||
return &ConfigManager{
|
||||
DistroInfo: distroInfo,
|
||||
BackupDir: "/etc/fastestmirror/backups",
|
||||
}
|
||||
}
|
||||
|
||||
// BackupConfig creates a backup of the current configuration
|
||||
func (cm *ConfigManager) BackupConfig() (string, error) {
|
||||
configFile := cm.DistroInfo.GetConfigFile()
|
||||
if configFile == "" {
|
||||
return "", fmt.Errorf("no configuration file defined for %s", cm.DistroInfo.Family)
|
||||
}
|
||||
|
||||
// Create backup directory if it doesn't exist
|
||||
if err := os.MkdirAll(cm.BackupDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create backup directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate backup filename with timestamp
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
backupName := fmt.Sprintf("%s.backup.%s", filepath.Base(configFile), timestamp)
|
||||
backupPath := filepath.Join(cm.BackupDir, backupName)
|
||||
|
||||
// Copy original file to backup location
|
||||
if err := cm.copyFile(configFile, backupPath); err != nil {
|
||||
return "", fmt.Errorf("failed to create backup: %w", err)
|
||||
}
|
||||
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
// ApplyMirror applies the fastest mirror to the configuration
|
||||
func (cm *ConfigManager) ApplyMirror(mirrorURL string) error {
|
||||
switch cm.DistroInfo.Family {
|
||||
case "debian":
|
||||
return cm.applyDebianMirror(mirrorURL)
|
||||
case "arch":
|
||||
return cm.applyArchMirror(mirrorURL)
|
||||
default:
|
||||
return fmt.Errorf("configuration management for %s family not implemented yet", cm.DistroInfo.Family)
|
||||
}
|
||||
}
|
||||
|
||||
// applyDebianMirror updates /etc/apt/sources.list for Debian-based systems
|
||||
func (cm *ConfigManager) applyDebianMirror(mirrorURL string) error {
|
||||
configFile := "/etc/apt/sources.list"
|
||||
|
||||
// Read current configuration
|
||||
file, err := os.Open(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", configFile, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
// Process each line and replace mirror URLs
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
// Keep comments and empty lines as-is
|
||||
lines = append(lines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse and update deb/deb-src lines
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "deb") {
|
||||
updatedLine := cm.updateDebianLine(line, mirrorURL)
|
||||
lines = append(lines, updatedLine)
|
||||
} else {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("error reading %s: %w", configFile, err)
|
||||
}
|
||||
|
||||
// Write updated configuration
|
||||
return cm.writeLines(configFile, lines)
|
||||
}
|
||||
|
||||
// updateDebianLine updates a single deb line with the new mirror URL
|
||||
func (cm *ConfigManager) updateDebianLine(line, newMirrorURL string) string {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 3 {
|
||||
return line // Invalid line, keep as-is
|
||||
}
|
||||
|
||||
// parts[0] = "deb" or "deb-src"
|
||||
// parts[1] = URL
|
||||
// parts[2] = suite/codename
|
||||
// parts[3+] = components
|
||||
|
||||
parts[1] = strings.TrimSuffix(newMirrorURL, "/") + "/"
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// applyArchMirror updates /etc/pacman.d/mirrorlist for Arch-based systems
|
||||
func (cm *ConfigManager) applyArchMirror(mirrorURL string) error {
|
||||
configFile := "/etc/pacman.d/mirrorlist"
|
||||
|
||||
// Read current configuration
|
||||
file, err := os.Open(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", configFile, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var lines []string
|
||||
firstMirrorAdded := false
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
// Add the fastest mirror at the top (uncommented)
|
||||
lines = append(lines, fmt.Sprintf("# FastestMirror: Added %s", time.Now().Format("2006-01-02 15:04:05")))
|
||||
lines = append(lines, fmt.Sprintf("Server = %s/$repo/os/$arch", strings.TrimSuffix(mirrorURL, "/")))
|
||||
lines = append(lines, "")
|
||||
firstMirrorAdded = true
|
||||
|
||||
// Process existing lines, commenting out other Server entries
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Comment out existing Server lines (but keep them for reference)
|
||||
if strings.HasPrefix(trimmed, "Server = ") && firstMirrorAdded {
|
||||
lines = append(lines, "#"+line)
|
||||
} else {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("error reading %s: %w", configFile, err)
|
||||
}
|
||||
|
||||
// Write updated configuration
|
||||
return cm.writeLines(configFile, lines)
|
||||
}
|
||||
|
||||
// copyFile copies a file from src to dst
|
||||
func (cm *ConfigManager) copyFile(src, dst string) error {
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
destFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, sourceFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy file permissions
|
||||
srcInfo, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Chmod(dst, srcInfo.Mode())
|
||||
}
|
||||
|
||||
// writeLines writes lines to a file
|
||||
func (cm *ConfigManager) writeLines(filename string, lines []string) error {
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
for _, line := range lines {
|
||||
if _, err := writer.WriteString(line + "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return writer.Flush()
|
||||
}
|
||||
|
||||
// RestoreBackup restores a configuration from backup
|
||||
func (cm *ConfigManager) RestoreBackup(backupPath string) error {
|
||||
configFile := cm.DistroInfo.GetConfigFile()
|
||||
if configFile == "" {
|
||||
return fmt.Errorf("no configuration file defined for %s", cm.DistroInfo.Family)
|
||||
}
|
||||
|
||||
return cm.copyFile(backupPath, configFile)
|
||||
}
|
191
internal/distro/detect.go
Normal file
191
internal/distro/detect.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package distro
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DistroInfo represents Linux distribution information
|
||||
type DistroInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Version string
|
||||
Family string
|
||||
Codename string
|
||||
}
|
||||
|
||||
// SupportedDistros lists all distributions we can handle
|
||||
var SupportedDistros = map[string]string{
|
||||
"ubuntu": "debian",
|
||||
"debian": "debian",
|
||||
"mint": "debian",
|
||||
"pop": "debian",
|
||||
"elementary": "debian",
|
||||
"kali": "debian",
|
||||
"arch": "arch",
|
||||
"manjaro": "arch",
|
||||
"endeavouros": "arch",
|
||||
"artix": "arch",
|
||||
"fedora": "fedora",
|
||||
"centos": "fedora",
|
||||
"rhel": "fedora",
|
||||
"rocky": "fedora",
|
||||
"alma": "fedora",
|
||||
"opensuse": "opensuse",
|
||||
"sles": "opensuse",
|
||||
"gentoo": "gentoo",
|
||||
"alpine": "alpine",
|
||||
"slackware": "slackware",
|
||||
"void": "void",
|
||||
}
|
||||
|
||||
// DetectDistribution attempts to detect the current Linux distribution
|
||||
func DetectDistribution() (*DistroInfo, error) {
|
||||
// Try /etc/os-release first (most reliable)
|
||||
if info, err := parseOSRelease(); err == nil {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Fallback to /etc/lsb-release
|
||||
if info, err := parseLSBRelease(); err == nil {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Last resort: check specific distribution files
|
||||
if info, err := detectFromSpecificFiles(); err == nil {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unable to detect Linux distribution - are you sure you're not running Windows? 🤔")
|
||||
}
|
||||
|
||||
// parseOSRelease parses /etc/os-release file
|
||||
func parseOSRelease() (*DistroInfo, error) {
|
||||
return parseKeyValueFile("/etc/os-release")
|
||||
}
|
||||
|
||||
// parseLSBRelease parses /etc/lsb-release file
|
||||
func parseLSBRelease() (*DistroInfo, error) {
|
||||
return parseKeyValueFile("/etc/lsb-release")
|
||||
}
|
||||
|
||||
// parseKeyValueFile is a generic parser for key=value format files
|
||||
func parseKeyValueFile(filename string) (*DistroInfo, error) {
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info := &DistroInfo{}
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.Trim(strings.TrimSpace(parts[1]), `"'`)
|
||||
|
||||
switch key {
|
||||
case "ID", "DISTRIB_ID":
|
||||
info.ID = strings.ToLower(value)
|
||||
case "NAME", "DISTRIB_DESCRIPTION":
|
||||
info.Name = value
|
||||
case "VERSION", "DISTRIB_RELEASE":
|
||||
info.Version = value
|
||||
case "VERSION_CODENAME", "DISTRIB_CODENAME":
|
||||
info.Codename = value
|
||||
}
|
||||
}
|
||||
|
||||
if info.ID == "" {
|
||||
return nil, fmt.Errorf("could not determine distribution ID")
|
||||
}
|
||||
|
||||
// Determine family
|
||||
if family, exists := SupportedDistros[info.ID]; exists {
|
||||
info.Family = family
|
||||
} else {
|
||||
info.Family = "unknown"
|
||||
}
|
||||
|
||||
return info, scanner.Err()
|
||||
}
|
||||
|
||||
// detectFromSpecificFiles tries to detect distro from specific files
|
||||
func detectFromSpecificFiles() (*DistroInfo, error) {
|
||||
// Map of files to expected distribution IDs
|
||||
checks := map[string]string{
|
||||
"/etc/arch-release": "arch",
|
||||
"/etc/gentoo-release": "gentoo",
|
||||
"/etc/slackware-version": "slackware",
|
||||
"/etc/alpine-release": "alpine",
|
||||
"/etc/void-release": "void",
|
||||
}
|
||||
|
||||
for file, distroID := range checks {
|
||||
if _, err := os.Stat(file); err == nil {
|
||||
info := &DistroInfo{
|
||||
ID: distroID,
|
||||
Name: strings.Title(distroID),
|
||||
Family: SupportedDistros[distroID],
|
||||
}
|
||||
|
||||
// Try to get version from file content
|
||||
if content, err := os.ReadFile(file); err == nil {
|
||||
versionRegex := regexp.MustCompile(`\d+\.?\d*\.?\d*`)
|
||||
if match := versionRegex.FindString(string(content)); match != "" {
|
||||
info.Version = match
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no recognizable distribution files found")
|
||||
}
|
||||
|
||||
// IsSupported checks if the distribution is supported
|
||||
func (d *DistroInfo) IsSupported() bool {
|
||||
_, exists := SupportedDistros[d.ID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// GetConfigFile returns the path to the main package configuration file
|
||||
func (d *DistroInfo) GetConfigFile() string {
|
||||
switch d.Family {
|
||||
case "debian":
|
||||
return "/etc/apt/sources.list"
|
||||
case "arch":
|
||||
return "/etc/pacman.d/mirrorlist"
|
||||
case "fedora":
|
||||
return "/etc/yum.repos.d" // Directory for multiple files
|
||||
case "opensuse":
|
||||
return "/etc/zypp/repos.d" // Directory for multiple files
|
||||
case "gentoo":
|
||||
return "/etc/portage/make.conf"
|
||||
case "alpine":
|
||||
return "/etc/apk/repositories"
|
||||
case "slackware":
|
||||
return "/etc/slackpkg/mirrors"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// String provides a human-readable representation
|
||||
func (d *DistroInfo) String() string {
|
||||
return fmt.Sprintf("%s %s (%s family)", d.Name, d.Version, d.Family)
|
||||
}
|
269
internal/mirror/fetcher.go
Normal file
269
internal/mirror/fetcher.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MirrorFetcher handles fetching mirror lists from official sources
|
||||
type MirrorFetcher struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewMirrorFetcher creates a new mirror fetcher
|
||||
func NewMirrorFetcher() *MirrorFetcher {
|
||||
return &MirrorFetcher{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// FetchMirrors gets the complete list of mirrors for a distribution family
|
||||
func (mf *MirrorFetcher) FetchMirrors(ctx context.Context, family string) ([]string, error) {
|
||||
switch family {
|
||||
case "arch":
|
||||
return mf.fetchArchMirrors(ctx)
|
||||
case "debian":
|
||||
return mf.fetchDebianMirrors(ctx)
|
||||
case "fedora":
|
||||
return mf.fetchFedoraMirrors(ctx)
|
||||
default:
|
||||
// Fallback to hardcoded mirrors for unsupported families
|
||||
return mf.getDefaultMirrors(family), nil
|
||||
}
|
||||
}
|
||||
|
||||
// fetchArchMirrors fetches Arch Linux mirrors from the official mirror status
|
||||
func (mf *MirrorFetcher) fetchArchMirrors(ctx context.Context) ([]string, error) {
|
||||
// Arch Linux provides a JSON API for mirror status
|
||||
url := "https://archlinux.org/mirrorlist/?protocol=https&use_mirror_status=on"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := mf.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Arch mirrors: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d from Arch mirror API", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return mf.parseArchMirrorList(string(body))
|
||||
}
|
||||
|
||||
// parseArchMirrorList parses the Arch Linux mirror list format
|
||||
func (mf *MirrorFetcher) parseArchMirrorList(content string) ([]string, error) {
|
||||
var mirrors []string
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
|
||||
// Look for lines like: #Server = https://mirror.example.com/archlinux/$repo/os/$arch
|
||||
serverRegex := regexp.MustCompile(`^#?Server\s*=\s*(.+)`)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if matches := serverRegex.FindStringSubmatch(line); matches != nil {
|
||||
mirrorURL := strings.TrimSpace(matches[1])
|
||||
// Remove the $repo/os/$arch part for base URL
|
||||
if strings.Contains(mirrorURL, "$repo") {
|
||||
mirrorURL = strings.Replace(mirrorURL, "/$repo/os/$arch", "/", 1)
|
||||
}
|
||||
if mirrorURL != "" && !strings.Contains(mirrorURL, "$") {
|
||||
mirrors = append(mirrors, mirrorURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(mirrors) == 0 {
|
||||
return nil, fmt.Errorf("no mirrors found in Arch Linux mirror list")
|
||||
}
|
||||
|
||||
return mirrors, nil
|
||||
}
|
||||
|
||||
// fetchDebianMirrors fetches Debian mirrors from the official mirror list
|
||||
func (mf *MirrorFetcher) fetchDebianMirrors(ctx context.Context) ([]string, error) {
|
||||
// Debian maintains a list of mirrors
|
||||
url := "https://www.debian.org/mirror/list-full"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := mf.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Debian mirrors: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d from Debian mirror list", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return mf.parseDebianMirrorList(string(body))
|
||||
}
|
||||
|
||||
// parseDebianMirrorList parses the Debian mirror list HTML
|
||||
func (mf *MirrorFetcher) parseDebianMirrorList(content string) ([]string, error) {
|
||||
var mirrors []string
|
||||
|
||||
// Look for HTTP/HTTPS URLs in the HTML
|
||||
urlRegex := regexp.MustCompile(`https?://[a-zA-Z0-9.-]+[a-zA-Z0-9.-/]*debian[a-zA-Z0-9.-/]*/?`)
|
||||
matches := urlRegex.FindAllString(content, -1)
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, match := range matches {
|
||||
// Clean up and normalize the URL
|
||||
mirror := strings.TrimSpace(match)
|
||||
mirror = strings.TrimSuffix(mirror, "/") + "/"
|
||||
|
||||
// Avoid duplicates and ensure it's a proper mirror
|
||||
if !seen[mirror] && strings.Contains(mirror, "debian") {
|
||||
mirrors = append(mirrors, mirror)
|
||||
seen[mirror] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Add some known good mirrors if we don't find enough
|
||||
knownMirrors := []string{
|
||||
"http://deb.debian.org/debian/",
|
||||
"http://ftp.us.debian.org/debian/",
|
||||
"http://ftp.uk.debian.org/debian/",
|
||||
"http://ftp.de.debian.org/debian/",
|
||||
"http://mirrors.kernel.org/debian/",
|
||||
}
|
||||
|
||||
for _, known := range knownMirrors {
|
||||
if !seen[known] {
|
||||
mirrors = append(mirrors, known)
|
||||
seen[known] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(mirrors) == 0 {
|
||||
return nil, fmt.Errorf("no mirrors found in Debian mirror list")
|
||||
}
|
||||
|
||||
return mirrors, nil
|
||||
}
|
||||
|
||||
// fetchFedoraMirrors fetches Fedora mirrors from the official mirror list
|
||||
func (mf *MirrorFetcher) fetchFedoraMirrors(ctx context.Context) ([]string, error) {
|
||||
// Fedora mirror list URL
|
||||
url := "https://mirrors.fedoraproject.org/mirrorlist?repo=fedora-39&arch=x86_64"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := mf.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Fedora mirrors: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d from Fedora mirror list", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return mf.parseFedoraMirrorList(string(body))
|
||||
}
|
||||
|
||||
// parseFedoraMirrorList parses the Fedora mirror list format
|
||||
func (mf *MirrorFetcher) parseFedoraMirrorList(content string) ([]string, error) {
|
||||
var mirrors []string
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "http") {
|
||||
// Extract base URL (remove specific paths)
|
||||
if idx := strings.Index(line, "/linux/"); idx != -1 {
|
||||
baseURL := line[:idx] + "/fedora/linux/"
|
||||
mirrors = append(mirrors, baseURL)
|
||||
} else {
|
||||
mirrors = append(mirrors, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(mirrors) == 0 {
|
||||
return nil, fmt.Errorf("no mirrors found in Fedora mirror list")
|
||||
}
|
||||
|
||||
return mirrors, nil
|
||||
}
|
||||
|
||||
// getDefaultMirrors returns hardcoded mirrors as fallback
|
||||
func (mf *MirrorFetcher) getDefaultMirrors(family string) []string {
|
||||
defaultMirrors := map[string][]string{
|
||||
"debian": {
|
||||
"http://deb.debian.org/debian/",
|
||||
"http://ftp.us.debian.org/debian/",
|
||||
"http://ftp.uk.debian.org/debian/",
|
||||
"http://ftp.de.debian.org/debian/",
|
||||
"http://mirrors.kernel.org/debian/",
|
||||
},
|
||||
"arch": {
|
||||
"https://mirror.rackspace.com/archlinux/",
|
||||
"https://mirrors.kernel.org/archlinux/",
|
||||
"https://mirror.math.princeton.edu/pub/archlinux/",
|
||||
"https://mirrors.mit.edu/archlinux/",
|
||||
"https://mirrors.liquidweb.com/archlinux/",
|
||||
},
|
||||
"fedora": {
|
||||
"https://download.fedoraproject.org/pub/fedora/linux/",
|
||||
"https://mirrors.kernel.org/fedora/",
|
||||
"https://mirror.math.princeton.edu/pub/fedora/linux/",
|
||||
"https://mirrors.mit.edu/fedora/linux/",
|
||||
},
|
||||
"opensuse": {
|
||||
"http://download.opensuse.org/distribution/",
|
||||
"http://ftp.gwdg.de/pub/linux/opensuse/distribution/",
|
||||
"http://ftp.halifax.rwth-aachen.de/opensuse/distribution/",
|
||||
},
|
||||
"gentoo": {
|
||||
"https://gentoo.osuosl.org/",
|
||||
"http://mirrors.kernel.org/gentoo/",
|
||||
"https://mirror.bytemark.co.uk/gentoo/",
|
||||
},
|
||||
"alpine": {
|
||||
"http://dl-cdn.alpinelinux.org/alpine/",
|
||||
"http://mirrors.dotsrc.org/alpine/",
|
||||
"http://mirror.fit.cvut.cz/alpine/",
|
||||
},
|
||||
}
|
||||
|
||||
if mirrors, exists := defaultMirrors[family]; exists {
|
||||
return mirrors
|
||||
}
|
||||
return []string{}
|
||||
}
|
463
internal/mirror/tester.go
Normal file
463
internal/mirror/tester.go
Normal file
@@ -0,0 +1,463 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mirror represents a package repository mirror
|
||||
type Mirror struct {
|
||||
URL string
|
||||
Country string
|
||||
Score float64
|
||||
Latency time.Duration
|
||||
Speed float64 // MB/s
|
||||
LastTest time.Time
|
||||
Error error
|
||||
}
|
||||
|
||||
// MirrorList manages a collection of mirrors
|
||||
type MirrorList struct {
|
||||
Mirrors []Mirror
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// TestResult contains the results of testing a mirror
|
||||
type TestResult struct {
|
||||
Mirror Mirror
|
||||
Success bool
|
||||
Error error
|
||||
}
|
||||
|
||||
// DefaultMirrors contains known mirrors for each distribution family
|
||||
var DefaultMirrors = map[string][]string{
|
||||
"debian": {
|
||||
"http://deb.debian.org/debian/",
|
||||
"http://ftp.us.debian.org/debian/",
|
||||
"http://ftp.uk.debian.org/debian/",
|
||||
"http://ftp.de.debian.org/debian/",
|
||||
"http://mirror.kakao.com/debian/",
|
||||
"http://mirrors.kernel.org/debian/",
|
||||
"http://mirror.math.princeton.edu/pub/debian/",
|
||||
"http://debian.osuosl.org/debian/",
|
||||
},
|
||||
"arch": {
|
||||
"https://mirror.rackspace.com/archlinux/",
|
||||
"https://mirrors.kernel.org/archlinux/",
|
||||
"https://mirror.math.princeton.edu/pub/archlinux/",
|
||||
"https://mirrors.mit.edu/archlinux/",
|
||||
"https://mirror.cs.vt.edu/pub/ArchLinux/",
|
||||
"https://mirrors.liquidweb.com/archlinux/",
|
||||
"https://arch.mirror.constant.com/",
|
||||
"https://america.mirror.pkgbuild.com/",
|
||||
},
|
||||
"fedora": {
|
||||
"https://download.fedoraproject.org/pub/fedora/linux/",
|
||||
"https://mirrors.kernel.org/fedora/",
|
||||
"https://mirror.math.princeton.edu/pub/fedora/linux/",
|
||||
"https://mirrors.mit.edu/fedora/linux/",
|
||||
"https://fedora.mirror.constant.com/",
|
||||
"https://mirror.cs.vt.edu/pub/fedora/linux/",
|
||||
"https://mirrors.liquidweb.com/fedora/",
|
||||
"https://ftp.osuosl.org/pub/fedora/linux/",
|
||||
},
|
||||
}
|
||||
|
||||
// NewMirrorList creates a new mirror list for the given distribution family
|
||||
func NewMirrorList(family string) *MirrorList {
|
||||
ml := &MirrorList{
|
||||
Mirrors: make([]Mirror, 0),
|
||||
}
|
||||
return ml
|
||||
}
|
||||
|
||||
// LoadMirrors fetches and loads mirrors for the distribution family
|
||||
func (ml *MirrorList) LoadMirrors(ctx context.Context, family string) error {
|
||||
fetcher := NewMirrorFetcher()
|
||||
|
||||
fmt.Printf("🌐 Fetching complete mirror list for %s...\n", family)
|
||||
|
||||
mirrors, err := fetcher.FetchMirrors(ctx, family)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ Failed to fetch mirrors online, using fallback list: %v\n", err)
|
||||
mirrors = fetcher.getDefaultMirrors(family)
|
||||
}
|
||||
|
||||
if len(mirrors) == 0 {
|
||||
return fmt.Errorf("no mirrors available for %s", family)
|
||||
}
|
||||
|
||||
ml.mutex.Lock()
|
||||
defer ml.mutex.Unlock()
|
||||
|
||||
ml.Mirrors = make([]Mirror, 0, len(mirrors))
|
||||
for _, mirrorURL := range mirrors {
|
||||
ml.Mirrors = append(ml.Mirrors, Mirror{
|
||||
URL: mirrorURL,
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Printf("📡 Loaded %d mirrors for testing\n", len(ml.Mirrors))
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestMirrors tests all mirrors concurrently and updates their scores
|
||||
func (ml *MirrorList) TestMirrors(ctx context.Context, timeout time.Duration) error {
|
||||
if len(ml.Mirrors) == 0 {
|
||||
return fmt.Errorf("no mirrors to test - this is awkward")
|
||||
}
|
||||
|
||||
ml.mutex.Lock()
|
||||
defer ml.mutex.Unlock()
|
||||
|
||||
results := make(chan TestResult, len(ml.Mirrors))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
fmt.Printf("🔄 Testing %d mirrors concurrently...\n", len(ml.Mirrors))
|
||||
|
||||
// Test each mirror concurrently
|
||||
for i := range ml.Mirrors {
|
||||
wg.Add(1)
|
||||
go func(mirror *Mirror) {
|
||||
defer wg.Done()
|
||||
testResult := ml.testSingleMirror(ctx, mirror, timeout)
|
||||
results <- testResult
|
||||
}(&ml.Mirrors[i])
|
||||
}
|
||||
|
||||
// Wait for all tests to complete
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
// Collect results and count successes
|
||||
successCount := 0
|
||||
failureCount := 0
|
||||
|
||||
for result := range results {
|
||||
for i := range ml.Mirrors {
|
||||
if ml.Mirrors[i].URL == result.Mirror.URL {
|
||||
ml.Mirrors[i] = result.Mirror
|
||||
if result.Success && result.Mirror.Error == nil {
|
||||
successCount++
|
||||
} else {
|
||||
failureCount++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Testing complete: %d successful, %d failed\n", successCount, failureCount)
|
||||
|
||||
// Sort by score (higher is better), but put failed mirrors at the end
|
||||
sort.Slice(ml.Mirrors, func(i, j int) bool {
|
||||
// If one has an error and the other doesn't, prioritize the working one
|
||||
if (ml.Mirrors[i].Error != nil) != (ml.Mirrors[j].Error != nil) {
|
||||
return ml.Mirrors[j].Error != nil // Working mirrors come first
|
||||
}
|
||||
// If both are working or both have errors, sort by score
|
||||
return ml.Mirrors[i].Score > ml.Mirrors[j].Score
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// testSingleMirror tests a single mirror's speed and latency
|
||||
func (ml *MirrorList) testSingleMirror(ctx context.Context, mirror *Mirror, timeout time.Duration) TestResult {
|
||||
testCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// Test latency with HEAD request first
|
||||
latency, err := ml.testLatency(testCtx, mirror.URL)
|
||||
if err != nil {
|
||||
return TestResult{
|
||||
Mirror: Mirror{
|
||||
URL: mirror.URL,
|
||||
Country: mirror.Country,
|
||||
Error: fmt.Errorf("latency test failed: %w", err),
|
||||
LastTest: time.Now(),
|
||||
},
|
||||
Success: false,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that the mirror actually serves the required package databases
|
||||
err = ml.validateMirror(testCtx, mirror.URL)
|
||||
if err != nil {
|
||||
return TestResult{
|
||||
Mirror: Mirror{
|
||||
URL: mirror.URL,
|
||||
Country: mirror.Country,
|
||||
Latency: latency,
|
||||
Error: fmt.Errorf("validation failed: %w", err),
|
||||
LastTest: time.Now(),
|
||||
},
|
||||
Success: false,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Test download speed with a small file
|
||||
speed, err := ml.testSpeed(testCtx, mirror.URL)
|
||||
if err != nil {
|
||||
return TestResult{
|
||||
Mirror: Mirror{
|
||||
URL: mirror.URL,
|
||||
Country: mirror.Country,
|
||||
Latency: latency,
|
||||
Error: fmt.Errorf("speed test failed: %w", err),
|
||||
LastTest: time.Now(),
|
||||
},
|
||||
Success: false,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate score based on speed and latency
|
||||
score := ml.calculateScore(speed, latency)
|
||||
|
||||
return TestResult{
|
||||
Mirror: Mirror{
|
||||
URL: mirror.URL,
|
||||
Country: mirror.Country,
|
||||
Score: score,
|
||||
Latency: latency,
|
||||
Speed: speed,
|
||||
LastTest: time.Now(),
|
||||
Error: nil,
|
||||
},
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
// testLatency measures response time to a mirror
|
||||
func (ml *MirrorList) testLatency(ctx context.Context, mirrorURL string) (time.Duration, error) {
|
||||
start := time.Now()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "HEAD", mirrorURL, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 2 * time.Second, // 2000ms timeout as requested
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return 0, fmt.Errorf("HTTP %d: mirror seems to be having a bad day", resp.StatusCode)
|
||||
}
|
||||
|
||||
return time.Since(start), nil
|
||||
}
|
||||
|
||||
// testSpeed measures download speed from a mirror
|
||||
func (ml *MirrorList) testSpeed(ctx context.Context, mirrorURL string) (float64, error) {
|
||||
// Try to download a standard test file (like Release file for Debian)
|
||||
testPath := ml.getTestPath(mirrorURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", testPath, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second, // Reasonable timeout for downloading test files
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return 0, fmt.Errorf("HTTP %d: couldn't download test file", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read response and measure time
|
||||
bytesRead, err := io.Copy(io.Discard, resp.Body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
duration := time.Since(start)
|
||||
if duration == 0 {
|
||||
return 0, fmt.Errorf("download completed too quickly to measure")
|
||||
}
|
||||
|
||||
// Calculate speed in MB/s
|
||||
speed := float64(bytesRead) / duration.Seconds() / (1024 * 1024)
|
||||
return speed, nil
|
||||
}
|
||||
|
||||
// validateMirror checks if the mirror actually serves the required package databases
|
||||
func (ml *MirrorList) validateMirror(ctx context.Context, mirrorURL string) error {
|
||||
// For Arch mirrors, test multiple repositories to ensure they're properly formatted
|
||||
if contains(mirrorURL, "archlinux") {
|
||||
// Test all major repositories that pacman needs
|
||||
repositories := []string{"core", "extra", "multilib"}
|
||||
|
||||
for _, repo := range repositories {
|
||||
testURL := mirrorURL + repo + "/os/x86_64/" + repo + ".db"
|
||||
err := ml.checkFileExists(ctx, testURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository %s not found: %w", repo, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For Debian mirrors, test Release file
|
||||
if contains(mirrorURL, "debian") {
|
||||
testURL := mirrorURL + "dists/stable/Release"
|
||||
return ml.checkFileExists(ctx, testURL)
|
||||
}
|
||||
|
||||
// For Fedora, test repomd.xml
|
||||
if contains(mirrorURL, "fedora") {
|
||||
testURL := mirrorURL + "releases/39/Everything/x86_64/os/repodata/repomd.xml"
|
||||
return ml.checkFileExists(ctx, testURL)
|
||||
}
|
||||
|
||||
// For unknown mirrors, just test basic connectivity
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkFileExists verifies that a specific file exists on the mirror
|
||||
func (ml *MirrorList) checkFileExists(ctx context.Context, fileURL string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "HEAD", fileURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return fmt.Errorf("required package database not found (404)")
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("HTTP %d: mirror doesn't serve required files", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTestPath returns an appropriate test file path for the mirror
|
||||
func (ml *MirrorList) getTestPath(mirrorURL string) string {
|
||||
// For Debian-based mirrors, try Release file
|
||||
if contains(mirrorURL, "debian") {
|
||||
return mirrorURL + "dists/stable/Release"
|
||||
}
|
||||
|
||||
// For Arch mirrors, try core.db
|
||||
if contains(mirrorURL, "archlinux") {
|
||||
return mirrorURL + "core/os/x86_64/core.db"
|
||||
}
|
||||
|
||||
// For Fedora, try repomd.xml
|
||||
if contains(mirrorURL, "fedora") {
|
||||
return mirrorURL + "releases/39/Everything/x86_64/os/repodata/repomd.xml"
|
||||
}
|
||||
|
||||
// Fallback: just try the base URL
|
||||
return mirrorURL
|
||||
}
|
||||
|
||||
// calculateScore calculates a score based on speed and latency
|
||||
func (ml *MirrorList) calculateScore(speed float64, latency time.Duration) float64 {
|
||||
// Higher speed is better, lower latency is better
|
||||
// Normalize and combine metrics
|
||||
speedScore := speed * 10 // Weight speed heavily
|
||||
latencyScore := 1000.0 / float64(latency.Milliseconds()) // Lower latency = higher score
|
||||
|
||||
return speedScore + latencyScore
|
||||
}
|
||||
|
||||
// GetBest returns the best performing mirror
|
||||
func (ml *MirrorList) GetBest() *Mirror {
|
||||
ml.mutex.RLock()
|
||||
defer ml.mutex.RUnlock()
|
||||
|
||||
if len(ml.Mirrors) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return the first mirror (should be highest scoring after sorting)
|
||||
for _, mirror := range ml.Mirrors {
|
||||
if mirror.Error == nil {
|
||||
return &mirror
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTop returns the top N mirrors (only successful ones)
|
||||
func (ml *MirrorList) GetTop(n int) []Mirror {
|
||||
ml.mutex.RLock()
|
||||
defer ml.mutex.RUnlock()
|
||||
|
||||
result := make([]Mirror, 0, n)
|
||||
count := 0
|
||||
|
||||
for _, mirror := range ml.Mirrors {
|
||||
if mirror.Error == nil && count < n {
|
||||
result = append(result, mirror)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAll returns all mirrors with their test results
|
||||
func (ml *MirrorList) GetAll() []Mirror {
|
||||
ml.mutex.RLock()
|
||||
defer ml.mutex.RUnlock()
|
||||
|
||||
result := make([]Mirror, len(ml.Mirrors))
|
||||
copy(result, ml.Mirrors)
|
||||
return result
|
||||
}
|
||||
|
||||
// contains is a helper function to check if a string contains a substring
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) &&
|
||||
(s == substr ||
|
||||
s[:len(substr)] == substr ||
|
||||
s[len(s)-len(substr):] == substr ||
|
||||
findInString(s, substr))
|
||||
}
|
||||
|
||||
func findInString(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
Reference in New Issue
Block a user