├── README.md ├── .gitignore ├── scriptrock_etcd_conf └── main.go ├── Makefile ├── common ├── fleet.go ├── common.go ├── config.go └── etcd.go └── client └── client.go /README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | .*.swo 3 | *~ 4 | client/peerdiscovery_query 5 | daemon/peerdiscovery_daemon 6 | build/ 7 | 8 | -------------------------------------------------------------------------------- /scriptrock_etcd_conf/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/ScriptRock/peerdiscovery/client" 4 | 5 | func main() { 6 | client.Client() 7 | } 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BUILD_DIR = build 2 | BINS = build/scriptrock_etcd_conf 3 | VERSION = 0.1.0 4 | OS = $(shell uname -s) 5 | ARCH = $(shell uname -m) 6 | PACKAGE_NAME = scriptrock_peerdiscovery-$(VERSION)-$(OS)-$(ARCH) 7 | TARBALL = $(PACKAGE_NAME).tar.gz 8 | 9 | GITHUB_RELEASE_URL = https://uploads.github.com/repos/ScriptRock/peerdiscovery/releases 10 | 11 | default: $(BINS) 12 | 13 | $(BINS): force 14 | 15 | $(BUILD_DIR)/%: %/main.go 16 | (cd ${ ${@F}) 25 | 26 | package: $(BUILD_DIR)/$(TARBALL) 27 | 28 | goxc_coreos: 29 | cd scriptrock_etcd_conf && goxc -bc="linux,amd64" -pv coreos -d=../build/ 30 | 31 | goxc: 32 | cd scriptrock_etcd_conf && goxc -bc="linux,!arm, darwin,!arm" -pv $(VERSION) -d=../build/ 33 | 34 | clean: force 35 | rm -rf $(BUILD_DIR)/ 36 | 37 | push_package_to_github: $(BUILD_DIR)/$(TARBALL) 38 | curl -H "Content-Type: application/x-compressed" --upload-file $< $(GITHUB_RELEASE_URL)/$(VERSION)/assets?name=${= 9 && fields[0] == "=" { 78 | a := &AvahiBrowseResult{ 79 | Type: fields[0], 80 | InterfaceName: fields[1], 81 | Protocol: fields[2], 82 | Name: fields[3], 83 | Service: fields[4], 84 | Domain: fields[5], 85 | Host: fields[6], 86 | IPString: fields[7], 87 | IPv4: nil, 88 | IPv6: nil, 89 | PortString: fields[8], 90 | Port: 0, 91 | } 92 | if v, err := strconv.Atoi(a.PortString); err == nil { 93 | a.Port = v 94 | } 95 | if a.Protocol == "IPv4" { 96 | a.IPv4 = net.ParseIP(a.IPString) 97 | } 98 | if a.Protocol == "IPv6" { 99 | a.IPv6 = net.ParseIP(a.IPString) 100 | } 101 | results = append(results, a) 102 | } 103 | } 104 | return results 105 | } 106 | 107 | func avahiPrefix() string { 108 | wrapperPath := "" 109 | 110 | wrapperPath = "/opt/usr/bin/" 111 | if _, err := os.Stat(wrapperPath); err == nil { 112 | return wrapperPath 113 | } 114 | wrapperPath = "/opt/scriptrock_utils/docker_avahi/docker_avahi_ssh.sh" 115 | if _, err := os.Stat(wrapperPath); err == nil { 116 | return wrapperPath + " " 117 | } 118 | return "" 119 | } 120 | 121 | func runAvahiPublish(id string, service string, port int) *os.Process { 122 | avahiWrapper := avahiPrefix() 123 | 124 | // poll loop until the daemon is up 125 | for { 126 | command := strings.TrimSpace(fmt.Sprintf("%savahi-browse --terminate -a", avahiWrapper)) 127 | commandParts := regexp.MustCompile("\\s+").Split(command, -1) 128 | if _, err := exec.Command(commandParts[0], commandParts[1:]...).Output(); err != nil { 129 | time.Sleep(time.Second) 130 | } else { 131 | break 132 | } 133 | } 134 | 135 | command := strings.TrimSpace(fmt.Sprintf("%savahi-publish-service %s %s %d", avahiWrapper, id, service, port)) 136 | commandParts := regexp.MustCompile("\\s+").Split(command, -1) 137 | 138 | cmd := exec.Command(commandParts[0], commandParts[1:]...) 139 | cmd.SysProcAttr = &syscall.SysProcAttr{} 140 | //cmd.SysProcAttr.Pdeathsig = syscall.SIGTERM 141 | cmd.SysProcAttr.Setsid = false 142 | cmd.SysProcAttr.Setpgid = false 143 | // start process 144 | if err := cmd.Start(); err != nil { 145 | fmt.Printf("Error starting avahi command: '%s' error '%s'\n", command, err.Error()) 146 | return nil 147 | } 148 | return cmd.Process 149 | } 150 | 151 | func runAvahiBrowse(service string, results chan *AvahiBrowseResult) { 152 | avahiWrapper := avahiPrefix() 153 | 154 | command := strings.TrimSpace(fmt.Sprintf("%savahi-browse --terminate --parsable --no-db-lookup --ignore-local --resolve %s", avahiWrapper, service)) 155 | commandParts := regexp.MustCompile("\\s+").Split(command, -1) 156 | 157 | out, err := exec.Command(commandParts[0], commandParts[1:]...).Output() 158 | if err != nil { 159 | fmt.Printf("Error running avahi: command '%s' error '%s'\n", command, err.Error()) 160 | } else { 161 | parsed := parseAvahiBrowse(out) 162 | for _, p := range parsed { 163 | results <- p 164 | } 165 | } 166 | } 167 | 168 | func (cs *ClientState) WriteAvahiServiceFile() { 169 | // wrap each peer in quotes 170 | conf := fmt.Sprintf( 171 | ` 172 | 173 | 174 | %s 175 | 176 | %s 177 | %d 178 | 179 | 180 | `, 181 | cs.cfg.UUID, 182 | cs.cfg.MDNSService, 183 | cs.etcd.PeerPort) 184 | 185 | fmt.Printf("Writing avahi conf file to '%s'\n", cs.cfg.AvahiConfPath) 186 | if err := ioutil.WriteFile(cs.cfg.AvahiConfPath, []byte(conf), 0644); err != nil { 187 | fmt.Printf("Could not write conf file '%s': %s\n", cs.cfg.AvahiConfPath, err.Error()) 188 | } 189 | } 190 | 191 | func (cs *ClientState) pollLoop() { 192 | for { 193 | runAvahiBrowse(cs.cfg.MDNSService, cs.mdnsPeerServerEntries) 194 | 195 | // run avahi browse to see nearby things 196 | cs.pollEvent <- 0 197 | time.Sleep(cs.cfg.PollInterval) 198 | } 199 | } 200 | 201 | func (cs *ClientState) checkEnt(ent *AvahiBrowseResult) (*net.Interface, net.IP, net.IP, error, error) { 202 | // only use IPv4 203 | peerIP := ent.IPv4 204 | if peerIP == nil { 205 | return nil, nil, nil, fmt.Errorf("No IPv4 address present"), nil 206 | } 207 | // This technically restricts valid configurations that go through routers. Will need to re-visit. 208 | // The objective is to prevent NATs where the return path will not work, not routers where the return path will. 209 | // Eventually, must set up a tcp/http server on the destination host that echoes back the connecting IP address, 210 | // and compare against that. 211 | // There is also an issue with multiple addresses on the same subnet on the same interface; but this is dumb anyway 212 | iface, _, myIP, err := common.LocalNetForIp(peerIP) 213 | if err == nil && myIP.Equal(peerIP) { 214 | return nil, nil, nil, fmt.Errorf("IP address is self (%s = %s)", myIP.String(), peerIP.String()), nil 215 | } 216 | if strings.HasPrefix(ent.Name, cs.cfg.UUID) { 217 | // This is bad; duplicate UUID from someone that isn't us. Presumably caused by a cloned VM. 218 | // In this case, panic, delete old id, die, and on the next respawn we'll regenerate the id 219 | common.DuplicateClusterInstanceUUID() 220 | fatalErr := fmt.Errorf("Prefix UUID is from self") 221 | return nil, nil, nil, fatalErr, fatalErr 222 | } 223 | return iface, myIP, peerIP, err, nil 224 | } 225 | 226 | func (cs *ClientState) peerMDNSHostname(ent *AvahiBrowseResult) string { 227 | return ent.Name 228 | } 229 | 230 | func (cs *ClientState) stateTask() (errOut error) { 231 | polls := 0 232 | lastPollWithHigherPeer := 0 233 | finished := false 234 | errOut = nil 235 | 236 | for !finished { 237 | select { 238 | case <-cs.pollEvent: 239 | // time to give up and write out a conf 240 | polls = polls + 1 241 | fmt.Printf("poll occurred\n") 242 | if polls >= lastPollWithHigherPeer+cs.cfg.MaxLoops { 243 | fmt.Printf("%d consecutive polls with no lower peer; exiting\n", cs.cfg.MaxLoops) 244 | finished = true 245 | } 246 | case ent := <-cs.mdnsPeerServerEntries: 247 | // peer etcd server. It may still be booting though. 248 | // do an HTTP request to the server to see if it truly exists 249 | if iface, localIP, peerIP, err, fatalErr := cs.checkEnt(ent); fatalErr != nil { 250 | finished = true 251 | fmt.Printf("Fatal error from peer server entry: %s\n", err.Error()) 252 | errOut = fatalErr 253 | } else if err != nil { 254 | fmt.Println("etcd server", ent, "invalid", err) 255 | } else { 256 | peerMDNSHostname := cs.peerMDNSHostname(ent) 257 | peerPort := ent.Port 258 | fmt.Printf("etcd server mDNS response: IP %s mDNS hostname %s\n", peerIP.String(), peerMDNSHostname) 259 | url := fmt.Sprintf("http://%s:%d/v2/keys/", peerIP.String(), cs.etcd.ClientPort) 260 | if _, err := http.Get(url); err != nil { 261 | fmt.Printf("Peer at '%s' not available yet: %s\n", url, err.Error()) 262 | cs.etcd.AddBootingPeer(iface, localIP, peerIP, peerPort) 263 | if localIP.String() < peerIP.String() { 264 | // we are lower; keep going 265 | } else { 266 | lastPollWithHigherPeer = polls 267 | } 268 | } else { 269 | fmt.Printf("Peer etcd server found on %s (%s); exiting\n", url, peerIP) 270 | cs.etcd.AddServerPeer(iface, localIP, peerIP, peerPort) 271 | cs.etcd.DiscoveryURL = "" 272 | finished = true 273 | } 274 | } 275 | case url := <-cs.discoveryURL: 276 | // url is already validated 277 | cs.etcd.DiscoveryURL = url 278 | finished = true 279 | } 280 | } 281 | 282 | return errOut 283 | } 284 | 285 | func (cs *ClientState) validateDiscoveryURL(url string) bool { 286 | if url != "" { 287 | if _, err := http.Get(url); err != nil { 288 | fmt.Printf("Poll discovery URL '%s' returns error: %s\n", url, err.Error()) 289 | } else { 290 | cs.discoveryURL <- url 291 | return true 292 | } 293 | } 294 | return false 295 | } 296 | 297 | func (cs *ClientState) checkDiscoveryURL() bool { 298 | if cs.validateDiscoveryURL(cs.etcd.DiscoveryURL) { 299 | return true 300 | } else if cs.validateDiscoveryURL(os.Getenv("ETCD_DISCOVERY")) { 301 | return true 302 | } else { 303 | // check file 304 | urlFile := "/etc/etcd/discovery_url" 305 | if fileData, err := ioutil.ReadFile(urlFile); err != nil { 306 | // no file; ignore 307 | } else { 308 | if cs.validateDiscoveryURL(strings.TrimSpace(string(fileData))) { 309 | return true 310 | } 311 | } 312 | } 313 | return false 314 | } 315 | 316 | func Client() { 317 | cfg, etcd, fleet, args, err := common.LoadConfigs() 318 | if err != nil || len(args) > 1 { 319 | fmt.Printf("Error parsing options; un-parsed options remain: %s\n", strings.Join(args[1:], ", ")) 320 | } else { 321 | cs := newClientState(cfg, etcd) 322 | 323 | cs.WriteAvahiServiceFile() 324 | 325 | // if a discovery URL is present, test it and publish if successful 326 | usingDiscoveryURL := cs.checkDiscoveryURL() 327 | 328 | // otherwise start mDNS polling 329 | if !usingDiscoveryURL { 330 | go cs.pollLoop() 331 | } 332 | 333 | err := cs.stateTask() 334 | if err == nil { 335 | etcd.WriteFile() 336 | fleet.WriteFile(etcd) 337 | } else { 338 | os.Exit(1) 339 | } 340 | } 341 | } 342 | --------------------------------------------------------------------------------