package bootstrap import ( "bytes" "context" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" "gopkg.in/yaml.v3" ) const maxSecretSize = 4096 type CandidateConfig struct { SecretPath string DataDir string TemplatePath string MihomoBinary string Client *http.Client } // PublishCandidate performs one Stage 1 update. Starting or reloading Mihomo is // deliberately left to the lifecycle stage. func PublishCandidate(ctx context.Context, config CandidateConfig) error { dataDir := filepath.Clean(config.DataDir) if !filepath.IsAbs(dataDir) || dataDir == string(filepath.Separator) { return fmt.Errorf("unsafe data directory %q", config.DataDir) } if err := ensureDirectory(dataDir); err != nil { return err } generations := filepath.Join(dataDir, "generations") if err := ensureDirectory(generations); err != nil { return err } for path, label := range map[string]string{ config.TemplatePath: "Mihomo template", config.MihomoBinary: "Mihomo binary", } { if err := validateSource(path, label); err != nil { return err } } endpoint, err := readSubscriptionSecret(config.SecretPath) if err != nil { return err } client := config.Client if client == nil { client = &http.Client{Timeout: 30 * time.Second} } subscription, err := fetchSubscription(ctx, client, endpoint) if err != nil { return err } subscription, err = normalizeSubscription(subscription) if err != nil { return err } template, err := os.ReadFile(config.TemplatePath) if err != nil { return errors.New("read Mihomo template") } generated, err := generateConfig(template) if err != nil { return err } current, err := currentGeneration(filepath.Join(dataDir, "last-good")) if err != nil { return err } next := "generations/a" if current == next { next = "generations/b" } candidate, err := os.MkdirTemp(generations, ".candidate-") if err != nil { return fmt.Errorf("create candidate generation: %w", err) } if err := os.Chmod(candidate, 0o700); err != nil { _ = os.RemoveAll(candidate) return fmt.Errorf("secure candidate generation: %w", err) } defer os.RemoveAll(candidate) configPath := filepath.Join(candidate, "config.yaml") if err := writePrivateFile(configPath, generated); err != nil { return fmt.Errorf("write candidate config: %w", err) } if err := writePrivateFile(filepath.Join(candidate, "subscription.yaml"), subscription); err != nil { return fmt.Errorf("write candidate subscription: %w", err) } if err := validateMihomoConfig(ctx, config.MihomoBinary, candidate, configPath); err != nil { return errors.New("candidate configuration failed Mihomo validation") } slot := filepath.Join(dataDir, filepath.FromSlash(next)) if err := os.RemoveAll(slot); err != nil { return fmt.Errorf("clear inactive generation: %w", err) } if err := os.Rename(candidate, slot); err != nil { return fmt.Errorf("publish candidate generation: %w", err) } if err := syncDirectory(generations); err != nil { return err } if err := replaceSymlink(filepath.Join(dataDir, "last-good"), next); err != nil { return err } return syncDirectory(dataDir) } func ensureDirectory(path string) error { info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { if err := os.MkdirAll(path, 0o700); err != nil { return fmt.Errorf("create data directory: %w", err) } return nil } if err != nil { return fmt.Errorf("inspect data directory: %w", err) } if !info.IsDir() { return fmt.Errorf("data path %q is not a directory", path) } return nil } func readSubscriptionSecret(path string) (string, error) { info, err := os.Lstat(path) if err != nil { return "", errors.New("read subscription secret") } if !info.Mode().IsRegular() || info.Size() == 0 || info.Size() > maxSecretSize { return "", errors.New("subscription secret must be a non-empty regular file") } file, err := os.Open(path) if err != nil { return "", errors.New("read subscription secret") } defer file.Close() opened, err := file.Stat() if err != nil || !os.SameFile(info, opened) { return "", errors.New("subscription secret changed while being read") } content, err := io.ReadAll(io.LimitReader(file, maxSecretSize+1)) if err != nil || len(content) > maxSecretSize { return "", errors.New("read subscription secret") } raw := strings.TrimSpace(string(content)) if strings.ContainsAny(raw, "\r\n") { return "", errors.New("subscription secret must contain one URL") } parsed, err := url.ParseRequestURI(raw) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil { return "", errors.New("subscription secret must contain one absolute HTTP(S) URL") } return raw, nil } func fetchSubscription(ctx context.Context, client *http.Client, endpoint string) ([]byte, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, errors.New("create subscription request") } request.Header.Set("Accept", "application/yaml, text/yaml, text/plain") request.Header.Set("User-Agent", "mihomo") response, err := client.Do(request) if err != nil { return nil, errors.New("subscription request failed") } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf("subscription endpoint returned HTTP %d", response.StatusCode) } content, err := io.ReadAll(io.LimitReader(response.Body, maxSubscriptionSize+1)) if err != nil { return nil, errors.New("read subscription response") } if len(content) == 0 || len(content) > maxSubscriptionSize { return nil, errors.New("subscription response is empty or too large") } return content, nil } func normalizeSubscription(content []byte) ([]byte, error) { decoder := yaml.NewDecoder(bytes.NewReader(content)) var document yaml.Node if err := decoder.Decode(&document); err != nil || len(document.Content) != 1 { return nil, errors.New("subscription YAML is invalid") } var extra yaml.Node if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { return nil, errors.New("subscription YAML must contain one document") } root := document.Content[0] if root.Kind != yaml.MappingNode { return nil, errors.New("subscription YAML must be a mapping") } var proxies *yaml.Node for index := 0; index < len(root.Content); index += 2 { if root.Content[index].Value != "proxies" { continue } if proxies != nil { return nil, errors.New("subscription YAML contains duplicate proxies fields") } proxies = root.Content[index+1] } if proxies == nil || proxies.Kind != yaml.SequenceNode || len(proxies.Content) == 0 { return nil, errors.New("subscription YAML must contain a non-empty proxies list") } for _, proxy := range proxies.Content { if proxy.Kind != yaml.MappingNode { return nil, errors.New("subscription YAML contains an invalid proxy") } } normalized := yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{ Kind: yaml.MappingNode, Content: []*yaml.Node{ {Kind: yaml.ScalarNode, Tag: "!!str", Value: "proxies"}, proxies, }, }}} return yaml.Marshal(&normalized) } func generateConfig(content []byte) ([]byte, error) { var document yaml.Node if err := yaml.Unmarshal(content, &document); err != nil || len(document.Content) != 1 { return nil, errors.New("Mihomo template is invalid") } root := document.Content[0] if root.Kind != yaml.MappingNode { return nil, errors.New("Mihomo template must be a mapping") } controller := mappingValue(root, "external-controller") if controller == nil || controller.Kind != yaml.ScalarNode { return nil, errors.New("Mihomo template is missing external-controller") } controller.Tag = "!!str" controller.Value = "0.0.0.0:9090" return yaml.Marshal(&document) } func mappingValue(mapping *yaml.Node, key string) *yaml.Node { for index := 0; index+1 < len(mapping.Content); index += 2 { if mapping.Content[index].Value == key { return mapping.Content[index+1] } } return nil } func currentGeneration(path string) (string, error) { info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { return "", nil } if err != nil { return "", fmt.Errorf("inspect last-good generation: %w", err) } if info.Mode()&os.ModeSymlink == 0 { return "", errors.New("last-good must be a managed symlink") } target, err := os.Readlink(path) if err != nil { return "", fmt.Errorf("read last-good generation: %w", err) } if target != "generations/a" && target != "generations/b" { return "", fmt.Errorf("last-good has unexpected target %q", target) } return target, nil } func writePrivateFile(path string, content []byte) error { return atomicWrite(path, 0o600, func(output *os.File) error { _, err := output.Write(content) return err }) } func replaceSymlink(path, target string) error { temporary, err := os.CreateTemp(filepath.Dir(path), ".last-good-") if err != nil { return fmt.Errorf("create last-good pointer: %w", err) } temporaryPath := temporary.Name() if err := temporary.Close(); err != nil { _ = os.Remove(temporaryPath) return fmt.Errorf("close last-good pointer: %w", err) } if err := os.Remove(temporaryPath); err != nil { return fmt.Errorf("prepare last-good pointer: %w", err) } defer os.Remove(temporaryPath) if err := os.Symlink(target, temporaryPath); err != nil { return fmt.Errorf("create last-good pointer: %w", err) } if err := os.Rename(temporaryPath, path); err != nil { return fmt.Errorf("publish last-good pointer: %w", err) } return nil } func syncDirectory(path string) error { directory, err := os.Open(path) if err != nil { return fmt.Errorf("open data directory for sync: %w", err) } defer directory.Close() if err := directory.Sync(); err != nil { return fmt.Errorf("sync data directory: %w", err) } return nil }