github.com/symfony-cli/symfony-cli@v0.0.0-20240514161054-ece2df437dfa/local/proxy/config.go (about)

     1  /*
     2   * Copyright (c) 2021-present Fabien Potencier <fabien@symfony.com>
     3   *
     4   * This file is part of Symfony CLI project
     5   *
     6   * This program is free software: you can redistribute it and/or modify
     7   * it under the terms of the GNU Affero General Public License as
     8   * published by the Free Software Foundation, either version 3 of the
     9   * License, or (at your option) any later version.
    10   *
    11   * This program is distributed in the hope that it will be useful,
    12   * but WITHOUT ANY WARRANTY; without even the implied warranty of
    13   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    14   * GNU Affero General Public License for more details.
    15   *
    16   * You should have received a copy of the GNU Affero General Public License
    17   * along with this program. If not, see <http://www.gnu.org/licenses/>.
    18   */
    19  
    20  package proxy
    21  
    22  import (
    23  	"encoding/json"
    24  	"fmt"
    25  	"log"
    26  	"net/http"
    27  	"os"
    28  	"path/filepath"
    29  	"regexp"
    30  	"strings"
    31  	"sync"
    32  
    33  	"github.com/elazarl/goproxy"
    34  	"github.com/mitchellh/go-homedir"
    35  	"github.com/pkg/errors"
    36  	"github.com/symfony-cli/symfony-cli/inotify"
    37  	"github.com/symfony-cli/symfony-cli/local/projects"
    38  	"github.com/symfony-cli/symfony-cli/util"
    39  )
    40  
    41  type Config struct {
    42  	TLD  string `json:"tld"`
    43  	Host string `json:"host"`
    44  	Port int    `json:"port"`
    45  	// only here so that we can unmarshal :(
    46  	TmpDomains map[string]string `json:"domains"`
    47  	path       string
    48  
    49  	mu      sync.RWMutex
    50  	domains map[string]string
    51  }
    52  
    53  var DefaultConfig = []byte(`{
    54  	"tld": "wip",
    55  	"host": "localhost",
    56  	"port": 7080,
    57  	"domains": {
    58  	}
    59  }
    60  `)
    61  
    62  func Load(homeDir string) (*Config, error) {
    63  	proxyFile := filepath.Join(homeDir, "proxy.json")
    64  	if _, err := os.Stat(proxyFile); os.IsNotExist(err) {
    65  		if err := os.MkdirAll(filepath.Dir(proxyFile), 0755); err != nil {
    66  			return nil, errors.Wrapf(err, "unable to create directory for %s", proxyFile)
    67  		}
    68  		if err := os.WriteFile(proxyFile, DefaultConfig, 0644); err != nil {
    69  			return nil, errors.Wrapf(err, "unable to write %s", proxyFile)
    70  		}
    71  	}
    72  	data, err := os.ReadFile(proxyFile)
    73  	if err != nil {
    74  		return nil, errors.Wrapf(err, "unable to read the proxy configuration file, %s", proxyFile)
    75  	}
    76  	var config *Config
    77  	if err := json.Unmarshal(data, &config); err != nil {
    78  		return nil, errors.Wrapf(err, "unable to parse the JSON proxy configuration file, %s", proxyFile)
    79  	}
    80  	if config.Host == "" {
    81  		config.Host = "localhost"
    82  	}
    83  	if config.TmpDomains == nil {
    84  		// happens if one has removed the domains manually in the file
    85  		config.domains = make(map[string]string)
    86  	} else {
    87  		config.SetDomains(config.TmpDomains)
    88  		config.TmpDomains = nil
    89  	}
    90  	config.path = proxyFile
    91  	return config, nil
    92  }
    93  
    94  func ToConfiguredProjects() (map[string]*projects.ConfiguredProject, error) {
    95  	ps := make(map[string]*projects.ConfiguredProject)
    96  	userHomeDir, err := homedir.Dir()
    97  	if err != nil {
    98  		userHomeDir = ""
    99  	}
   100  
   101  	homeDir := util.GetHomeDir()
   102  	proxyConf, err := Load(homeDir)
   103  	if err != nil {
   104  		return nil, err
   105  	}
   106  	dirs := proxyConf.Dirs()
   107  	for dir := range dirs {
   108  		shortDir := dir
   109  		if strings.HasPrefix(dir, userHomeDir) {
   110  			shortDir = "~" + dir[len(userHomeDir):]
   111  		}
   112  
   113  		ps[shortDir] = &projects.ConfiguredProject{
   114  			Domains: proxyConf.GetDomains(dir),
   115  			Scheme:  "https",
   116  		}
   117  	}
   118  	return ps, nil
   119  }
   120  
   121  func (c *Config) Domains() map[string]string {
   122  	c.mu.Lock()
   123  	defer c.mu.Unlock()
   124  	return c.domains
   125  }
   126  
   127  func (c *Config) Dirs() map[string][]string {
   128  	c.mu.Lock()
   129  	defer c.mu.Unlock()
   130  	dirs := map[string][]string{}
   131  	for dir, domain := range c.domains {
   132  		dirs[domain] = append(dirs[domain], dir)
   133  	}
   134  	return dirs
   135  }
   136  
   137  func (c *Config) NormalizeDomain(domain string) string {
   138  	c.mu.Lock()
   139  	defer c.mu.Unlock()
   140  	return c.doNormalizeDomain(domain)
   141  }
   142  
   143  func (c *Config) GetDir(domain string) string {
   144  	c.mu.Lock()
   145  	defer c.mu.Unlock()
   146  	return c.domains[c.domainWithoutTLD(c.doNormalizeDomain(domain))]
   147  }
   148  
   149  func (c *Config) GetDomains(dir string) []string {
   150  	c.mu.Lock()
   151  	defer c.mu.Unlock()
   152  	domains := []string{}
   153  	for domain, d := range c.domains {
   154  		if d == dir {
   155  			domains = append(domains, domain+"."+c.TLD)
   156  		}
   157  	}
   158  	return domains
   159  }
   160  
   161  func (c *Config) SetDomains(domains map[string]string) {
   162  	c.mu.Lock()
   163  	c.domains = domains
   164  	c.mu.Unlock()
   165  }
   166  
   167  func (c *Config) ReplaceDirDomains(dir string, domains []string) error {
   168  	c.mu.Lock()
   169  	defer c.mu.Unlock()
   170  	for domain, d := range c.domains {
   171  		if d == dir {
   172  			delete(c.domains, domain)
   173  		}
   174  	}
   175  	for _, d := range domains {
   176  		if strings.HasSuffix(d, c.TLD) {
   177  			return errors.Errorf(`domain "%s" must not end with the "%s" TLD, please remove the TLD`, d, c.TLD)
   178  		}
   179  		c.domains[d] = dir
   180  	}
   181  	return c.Save()
   182  }
   183  
   184  func (c *Config) AddDirDomains(dir string, domains []string) error {
   185  	c.mu.Lock()
   186  	defer c.mu.Unlock()
   187  	for _, d := range domains {
   188  		if strings.HasSuffix(d, c.TLD) {
   189  			return errors.Errorf(`domain "%s" must not end with the "%s" TLD, please remove the TLD`, d, c.TLD)
   190  		}
   191  		c.domains[d] = dir
   192  	}
   193  	return c.Save()
   194  }
   195  
   196  func (c *Config) RemoveDirDomains(domains []string) error {
   197  	c.mu.Lock()
   198  	defer c.mu.Unlock()
   199  	for _, d := range domains {
   200  		if strings.HasSuffix(d, c.TLD) {
   201  			return errors.Errorf(`domain "%s" must not end with the "%s" TLD, please remove the TLD`, d, c.TLD)
   202  		}
   203  		delete(c.domains, d)
   204  	}
   205  	return c.Save()
   206  }
   207  
   208  // Watch checks config file changes
   209  func (c *Config) Watch() {
   210  	watcherChan := make(chan inotify.EventInfo, 1)
   211  	if err := inotify.Watch(c.path, watcherChan, inotify.Write); err != nil {
   212  		log.Printf("unable to watch proxy config file: %s", err)
   213  	}
   214  	defer inotify.Stop(watcherChan)
   215  	for {
   216  		<-watcherChan
   217  		c.reload()
   218  	}
   219  }
   220  
   221  // reloads the TLD and the domains (not the port)
   222  func (c *Config) reload() {
   223  	data, err := os.ReadFile(c.path)
   224  	if err != nil {
   225  		return
   226  	}
   227  	var config Config
   228  	if err := json.Unmarshal(data, &config); err != nil {
   229  		return
   230  	}
   231  	c.SetDomains(config.TmpDomains)
   232  	c.mu.Lock()
   233  	c.TLD = config.TLD
   234  	c.mu.Unlock()
   235  }
   236  
   237  func (c *Config) tldMatches() goproxy.ReqConditionFunc {
   238  	re := regexp.MustCompile(fmt.Sprintf("\\.%s(\\:\\d+)?$", c.TLD))
   239  
   240  	return func(req *http.Request, ctx *goproxy.ProxyCtx) bool {
   241  		return re.MatchString(req.Host)
   242  	}
   243  }
   244  
   245  func (c *Config) Save() error {
   246  	c.TmpDomains = c.domains
   247  	data, err := json.MarshalIndent(c, "", "    ")
   248  	if err != nil {
   249  		return errors.WithStack(err)
   250  	}
   251  	return errors.WithStack(os.WriteFile(c.path, data, 0644))
   252  }
   253  
   254  // should be called with a lock a place
   255  // always returns a domain with the TLD
   256  func (c *Config) doNormalizeDomain(domain string) string {
   257  	domain = c.domainWithoutTLD(domain)
   258  	fqdn := domain + "." + c.TLD
   259  	if _, ok := c.domains[domain]; ok {
   260  		return fqdn
   261  	}
   262  	match := ""
   263  	for d := range c.domains {
   264  		if !strings.Contains(d, "*") {
   265  			continue
   266  		}
   267  		// glob matching
   268  		if strings.HasSuffix(domain, strings.Replace(d, "*.", ".", -1)) {
   269  			m := d + "." + c.TLD
   270  			// always use the longest possible domain for matching
   271  			if len(m) > len(match) {
   272  				match = m
   273  			}
   274  		}
   275  	}
   276  	if match != "" {
   277  		return match
   278  	}
   279  	return fqdn
   280  }
   281  
   282  func (c *Config) domainWithoutTLD(domain string) string {
   283  	if strings.HasSuffix(domain, "."+c.TLD) {
   284  		return domain[:len(domain)-len(c.TLD)-1]
   285  	}
   286  	return domain
   287  }