package icecast import ( "context" "errors" "flag" "fmt" "log" "net/http" "net/url" "os" "os/exec" "sync" "time" "git.autistici.org/ale/autoradio" pb "git.autistici.org/ale/autoradio/proto" "git.autistici.org/ale/autoradio/util" ) var ( // Timeout for all HTTP requests to the local Icecast. icecastHTTPTimeout = 5 * time.Second icecastReloadCmd = flag.String("icecast-reload-command", "pkill -HUP icecast2", "Command to reload the icecast2 daemon") ) // IcecastController manages a local Icecast daemon. // // Managing the local Icecast instance is a less simple task than it // might look, as the details tend to be distribution-specific. We'd // like to avoid implementing yet another service manager within // radiod, so we are just going to assume that the Icecast daemon is // managed using the local distribution-specific tools, and is running // independently of radiod. Furthermore, we're going to make the // following assumptions: // // - We have permissions to modify the Icecast config file; // // - We have installed our status XSLT page; // // - We can reload the Icecast service (send the icecast2 process a // SIGHUP). Ideally we'd like to use the distribution-specific method, // but with systemd we need to use "sudo". The code tries to // autodetect a supported mechanism. // // The above is usually accomplished by running radiod as the same // user that is running the Icecast daemon. // type Controller struct { configPath string adminPw string port int statusMx sync.Mutex icecastOk bool mounts []*pb.IcecastMount } // NewController returns a new Controller that manages a // (independently started) local Icecast daemon. The context is used // to cancel background processing. func NewController(ctx context.Context, port int, configPath, adminPwPath string) (*Controller, error) { pw, err := getAdminPassword(adminPwPath) if err != nil { return nil, fmt.Errorf("couldn't initialize icecast admin password: %v", err) } ic := &Controller{ configPath: configPath, adminPw: pw, port: port, } go ic.statusUpdater(ctx) return ic, nil } // Update reloads the Icecast daemon with a new configuration. func (c *Controller) Update(ctx context.Context, mounts []*pb.Mount, isMaster bool, masterAddr string) error { if !isMaster && masterAddr == "" { return errors.New("unknown/invalid system state") } conf := newIcecastConfig( mounts, c.port, 1000, // maxClients c.adminPw, isMaster, masterAddr, ) data, err := conf.Encode() if err != nil { return err } changed, err := util.WriteFileIfChanged(c.configPath, data) if err != nil { return err } if changed { c.killSources(ctx, mounts) err = c.reload() } return err } func (c *Controller) reload() error { log.Printf("reloading icecast") cmd := exec.Command("/bin/sh", "-c", *icecastReloadCmd) cmd.Dir = "/" cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr return cmd.Run() } var httpClient = &http.Client{} func (c *Controller) killSources(ctx context.Context, mounts []*pb.Mount) { for _, m := range mounts { kctx, cancel := context.WithTimeout(ctx, icecastHTTPTimeout) if err := killSource(kctx, m, c.port, c.adminPw); err != nil { log.Printf("kill_sources: %s: error: %v", m.Path, err) } cancel() } } func killSource(ctx context.Context, m *pb.Mount, port int, pw string) error { v := make(url.Values) v.Set("mount", autoradio.MountPathToIcecastPath(m.Path)) req, err := http.NewRequest("GET", fmt.Sprintf("http://localhost:%d/admin/killsource?%s", port, v.Encode()), nil) if err != nil { return err } req.SetBasicAuth("admin", pw) resp, err := httpClient.Do(req.WithContext(ctx)) if err != nil { return err } resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("HTTP status %d", resp.StatusCode) } return nil }