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 }