diff --git a/node/icecast.go b/node/icecast.go index e63c8ccb4d6a6a6a57abad3d6d4065b41e87a822..a9c0bc3092d15fb60731cc357ae4ffbf9d74c091 100644 --- a/node/icecast.go +++ b/node/icecast.go @@ -15,6 +15,7 @@ import ( "git.autistici.org/ale/autoradio" "git.autistici.org/ale/autoradio/instrumentation" + "git.autistici.org/ale/autoradio/util" ) var ( @@ -67,6 +68,7 @@ func NewIcecastController(publicIp string, maxClients int) *icecastController { // Reload the icecast daemon. Redirects output to our standard error // for debugging purposes. func (ic *icecastController) reload() error { + log.Printf("reloading icecast") cmd := exec.Command("/bin/sh", "-c", icecastReloadCmd) cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr @@ -74,27 +76,28 @@ func (ic *icecastController) reload() error { } // Kill sources connected to local streams. -func (ic *icecastController) killSources(conf *clusterConfig) error { - var anyErr error +func (ic *icecastController) killSources(conf *clusterConfig) { client := &http.Client{} for _, m := range conf.ListMounts() { req, err := http.NewRequest("GET", fmt.Sprintf("http://localhost:%d/admin/killsource?mount=%s", autoradio.IcecastPort, autoradio.MountNameToIcecastPath(m.Name)), nil) if err != nil { - anyErr = err + log.Printf("killSources: %v", err) continue } req.SetBasicAuth("admin", getIcecastAdminPassword()) resp, err := client.Do(req) if err != nil { - anyErr = err + log.Printf("killSources: %v", err) continue } resp.Body.Close() - if resp.StatusCode != 200 { - anyErr = fmt.Errorf("HTTP status %s", resp.Status) + // Do not report errors when killing sources on relay + // mounts - we only do it in the off chance that the + // mount was *not* a relay before. + if !m.IsRelay() && resp.StatusCode != 200 { + log.Printf("killSources: HTTP status %s", resp.Status) } } - return anyErr } // Update reloads the Icecast daemon with a new configuration. @@ -103,28 +106,36 @@ func (ic *icecastController) Update(conf *clusterConfig, isMaster bool, masterAd return errors.New("unknown system state") } - // Try to kill sources connected to the local icecast daemon - // before reloading, otherwise we'll have problems on master - // -> slave transitions (for example, on a restart of radiod) - // as sources will be "stuck" preventing the new configuration - // from taking effect. - if err := ic.killSources(conf); err != nil { - log.Printf("error killing sources: %v", err) - } - // Write a new configuration (atomically). ic.config.Update(conf, isMaster, masterAddr.String()) - tmpf := icecastConfigFile + ".tmp" - defer os.Remove(tmpf) - if err := ic.config.EncodeToFile(tmpf); err != nil { + data, err := ic.config.Encode() + if err != nil { return err } - if err := os.Rename(tmpf, icecastConfigFile); err != nil { + + changed, err := util.WriteFileIfChanged(icecastConfigFile, data) + if err != nil { return err } + if changed { + // Try to kill sources connected to the local icecast daemon + // before reloading, otherwise we'll have problems on master + // -> slave transitions (for example, on a restart of radiod) + // as sources will be "stuck" preventing the new configuration + // from taking effect. + // + // There is unfortunately a small race condition here, as it + // is possible for sources to reconnect before we reload the + // icecast daemon. + // + // TODO: only kill sources whose configuration has changed. + ic.killSources(conf) + + // Reload the icecast daemon. + return ic.reload() + } - // Tell the Icecast daemon to reload its configuration. - return ic.reload() + return nil } func (ic *icecastController) GetStatus() *IcecastStatus { diff --git a/node/icecast_config.go b/node/icecast_config.go index ffa8dd67517ae2b836df762070b556b0740e7168..be8553632c1ecbe656a3ee34eecc5562aa481a71 100644 --- a/node/icecast_config.go +++ b/node/icecast_config.go @@ -9,7 +9,7 @@ import ( "log" "net" "net/url" - "os" + "sort" "strconv" "git.autistici.org/ale/autoradio" @@ -195,22 +195,6 @@ func (c *icecastConfig) Encode() ([]byte, error) { return buf.Bytes(), nil } -// EncodeToFile writes the configuration to a file. -func (c *icecastConfig) EncodeToFile(path string) error { - file, err := os.Create(path) - if err != nil { - return err - } - defer file.Close() - - data, err := c.Encode() - if err != nil { - return err - } - _, err = file.Write(data) - return err -} - func masterMountToIcecastConfig(m *autoradio.Mount) iceMountConfig { mconfig := iceMountConfig{ Name: autoradio.MountNameToIcecastPath(m.Name), @@ -274,14 +258,26 @@ func slaveMountToIcecastConfig(masterAddr string, m *autoradio.Mount) iceRelayCo } } +type mountList []*autoradio.Mount + +func (l mountList) Len() int { return len(l) } +func (l mountList) Swap(i, j int) { l[i], l[j] = l[j], l[i] } +func (l mountList) Less(i, j int) bool { + return l[i].Name < l[j].Name +} + // Update the configuration with the current list of mounts and // masterelection state. This will clear the Mounts and Relays fields -// and set them to new values. +// and set them to new values. The mounts are sorted by name so that +// the XML representation generated by Encode() is consistent. func (ic *icecastConfig) Update(config *clusterConfig, isMaster bool, masterAddr string) { var mounts []iceMountConfig var relays []iceRelayConfig - for _, m := range config.ListMounts() { + allMounts := config.ListMounts() + sort.Sort(mountList(allMounts)) + + for _, m := range allMounts { switch { case m.IsRelay(): if rc, ok := relayToIcecastConfig(m); ok { diff --git a/node/icecast_config_test.go b/node/icecast_config_test.go index b3e1f2b2ccfb1fe3cc22a0e14ed731116fd0d706..8794a57b0108053bea43b4e1d00240844ec4fdd1 100644 --- a/node/icecast_config_test.go +++ b/node/icecast_config_test.go @@ -1,45 +1,213 @@ package node import ( + "bytes" + "fmt" "strings" "testing" "git.autistici.org/ale/autoradio" + "github.com/aryann/difflib" ) +const ( + expectedSlaveConfig = `<!-- Automatically generated, do not edit --> + +<icecast> + <limits> + <clients>1000</clients> + <sources>500</sources> + <queue-size>1048576</queue-size> + <client-timeout>30</client-timeout> + <header-timeout>15</header-timeout> + <source-timeout>60</source-timeout> + <burst-size>131072</burst-size> + </limits> + <authentication> + <source-password>sourcepass</source-password> + <admin-user>admin</admin-user> + <admin-password>adminpass</admin-password> + </authentication> + <hostname>1.2.3.4</hostname> + <fileserve>1</fileserve> + <paths> + <basedir>/usr/share/icecast2</basedir> + <logdir>/var/log/icecast2</logdir> + <webroot>/usr/share/icecast2/web</webroot> + <adminroot>/usr/share/icecast2/admin</adminroot> + </paths> + <logging> + <accesslog>access.log</accesslog> + <errorlog>error.log</errorlog> + <loglevel>3</loglevel> + <logsize>10000</logsize> + </logging> + <security> + <chroot>0</chroot> + </security> + <listen-socket> + <bind-address>0.0.0.0</bind-address> + <port>8000</port> + <shoutcast-compat>0</shoutcast-compat> + </listen-socket> + <relay> + <server>2.3.4.5</server> + <port>8000</port> + <mount>/_stream/stream1.ogg</mount> + <local-mount>/_stream/stream1.ogg</local-mount> + <username>user</username> + <password>pass</password> + <on-demand>1</on-demand> + <relay-shoutcast-metadata>1</relay-shoutcast-metadata> + </relay> + <relay> + <server>example.com</server> + <port>80</port> + <mount>/stream2.ogg</mount> + <local-mount>/_stream/stream2.ogg</local-mount> + <username></username> + <password></password> + <on-demand>1</on-demand> + <relay-shoutcast-metadata>1</relay-shoutcast-metadata> + </relay> + <relay> + <server>2.3.4.5</server> + <port>8000</port> + <mount>/_stream/stream3.mp3</mount> + <local-mount>/_stream/stream3.mp3</local-mount> + <username>user</username> + <password>pass</password> + <on-demand>1</on-demand> + <relay-shoutcast-metadata>1</relay-shoutcast-metadata> + </relay> +</icecast>` + + expectedMasterConfig = `<!-- Automatically generated, do not edit --> + +<icecast> + <limits> + <clients>1000</clients> + <sources>500</sources> + <queue-size>1048576</queue-size> + <client-timeout>30</client-timeout> + <header-timeout>15</header-timeout> + <source-timeout>60</source-timeout> + <burst-size>131072</burst-size> + </limits> + <authentication> + <source-password>sourcepass</source-password> + <admin-user>admin</admin-user> + <admin-password>adminpass</admin-password> + </authentication> + <hostname>1.2.3.4</hostname> + <fileserve>1</fileserve> + <paths> + <basedir>/usr/share/icecast2</basedir> + <logdir>/var/log/icecast2</logdir> + <webroot>/usr/share/icecast2/web</webroot> + <adminroot>/usr/share/icecast2/admin</adminroot> + </paths> + <logging> + <accesslog>access.log</accesslog> + <errorlog>error.log</errorlog> + <loglevel>3</loglevel> + <logsize>10000</logsize> + </logging> + <security> + <chroot>0</chroot> + </security> + <listen-socket> + <bind-address>0.0.0.0</bind-address> + <port>8000</port> + <shoutcast-compat>0</shoutcast-compat> + </listen-socket> + <relay> + <server>example.com</server> + <port>80</port> + <mount>/stream2.ogg</mount> + <local-mount>/_stream/stream2.ogg</local-mount> + <username></username> + <password></password> + <on-demand>1</on-demand> + <relay-shoutcast-metadata>1</relay-shoutcast-metadata> + </relay> + <mount> + <mount-name>/_stream/stream1.ogg</mount-name> + <username>user</username> + <password>pass</password> + <fallback-mount>/fallback.ogg</fallback-mount> + <fallback-override>1</fallback-override> + <hidden>0</hidden> + </mount> + <mount> + <mount-name>/_stream/stream3.mp3</mount-name> + <username>user</username> + <password>pass</password> + <hidden>0</hidden> + </mount> +</icecast>` +) + +// Create an icecastConfig object with known passwords. +func createTestIcecastConfig() *icecastConfig { + ic := newIcecastConfig("1.2.3.4", 1000) + ic.Auth.SourcePassword = "sourcepass" + ic.Auth.AdminPassword = "adminpass" + return ic +} + +func checkStrings(t *testing.T, got, want string) { + if got == want { + return + } + var diffout bytes.Buffer + for _, d := range difflib.Diff(strings.Split(want, "\n"), strings.Split(got, "\n")) { + fmt.Fprintf(&diffout, "%s\n", d.String()) + } + t.Errorf("unexpected result: (+ got, - want)\n\n%s\n", diffout.String()) +} + func TestIcecastConfig(t *testing.T) { - mount := &autoradio.Mount{ - Name: "/test.ogg", + // Create a test config with a few different mount types. + c := newClusterConfig() + c.setMountIfChanged(&autoradio.Mount{ + Name: "/stream1.ogg", Username: "user", Password: "pass", - } - c := newClusterConfig() - c.setMountIfChanged(mount) + Fallback: "/fallback.ogg", + }) + c.setMountIfChanged(&autoradio.Mount{ + Name: "/stream2.ogg", + RelayUrl: "http://example.com/stream2.ogg", + }) + c.setMountIfChanged(&autoradio.Mount{ + Name: "/stream3.mp3", + Username: "user", + Password: "pass", + Transcoding: &autoradio.EncodingParams{ + SourceName: "/stream2.ogg", + Format: "mp3", + BitRate: 64, + SampleRate: 44100, + Channels: 2, + }, + }) // Test a relay config. - ice := newIcecastConfig("1.2.3.4", 1000) + ice := createTestIcecastConfig() ice.Update(c, false, "2.3.4.5") output, err := ice.Encode() if err != nil { t.Fatal(err) } - outputs := string(output) - if !strings.Contains(outputs, "<icecast>") { - t.Fatalf("No <icecast> element:\n%s", output) - } - if !strings.Contains(outputs, "<relay>") { - t.Fatalf("Mount not configured as relay:\n%s", output) - } + checkStrings(t, string(output), expectedSlaveConfig) // Test a master config. - ice = newIcecastConfig("1.2.3.4", 1000) + ice = createTestIcecastConfig() ice.Update(c, true, "2.3.4.5") output, err = ice.Encode() if err != nil { t.Fatal(err) } - outputs = string(output) - if !strings.Contains(outputs, "<mount>") { - t.Fatalf("Mount not configured as master:\n%s", output) - } + checkStrings(t, string(output), expectedMasterConfig) } diff --git a/node/node.go b/node/node.go index 72295be705a18ea38090ebb50494f1186eade3fb..152429cd1414e175fec5777e24aad105afda4bbe 100644 --- a/node/node.go +++ b/node/node.go @@ -406,11 +406,12 @@ func (rc *RadioNode) updater(stop chan bool) { continue } + rc.Log.Printf("updating configuration") + masterAddr := rc.getMasterAddr() // Reload the Icecast daemon. icecastReloads.Incr() - rc.Log.Printf("reloading icecast config") if err := rc.icecast.Update(rc.config, rc.me.IsMaster(), masterAddr); err != nil { icecastReloadErrors.Incr() rc.Log.Printf("Update(): %v", err) diff --git a/util/write_file.go b/util/write_file.go new file mode 100644 index 0000000000000000000000000000000000000000..dee38834d8fde23c059149f997ce5d3c33a23b6c --- /dev/null +++ b/util/write_file.go @@ -0,0 +1,29 @@ +package util + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" +) + +// WriteFileIfChanged updates the contents of a file if they have +// changed. It returns false if the file already exists and its +// contents are equal to 'data', true in any other case. +func WriteFileIfChanged(path string, data []byte) (bool, error) { + if cur, err := ioutil.ReadFile(path); err == nil && bytes.Equal(cur, data) { + return false, nil + } + + tmpf, err := ioutil.TempFile(filepath.Dir(path), ".tmp") + if err != nil { + return true, err + } + defer os.Remove(tmpf.Name()) + + tmpf.Write(data) + tmpf.Close() + os.Chmod(tmpf.Name(), 0644) + + return true, os.Rename(tmpf.Name(), path) +}