commit b7ff98764bcfc076a2130bbf9c9a13061661a77d Author: James Coleman Date: Thu May 13 18:05:41 2021 -0500 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cffcb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.json \ No newline at end of file diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..31421ad --- /dev/null +++ b/License.txt @@ -0,0 +1,19 @@ +Copyright (c) 2021 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fae9572 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Asterisk Outgoing Call API + +Starts an outgoing call based on provided paramters according to the https://wiki.asterisk.org/wiki/display/AST/Asterisk+Call+Files call file format. View https://www.voip-info.org/asterisk-auto-dial-out/ for more details about the call file format. I created this to make my phone ring when my cell phone rings via a tasker profile. Should be useful for many additional things though. + +# Accepted parameters + +- token: The API token to authenticate with the server. +- channel: Channel to use for the call. +- caller_id: Caller ID, Please note: It may not work if you do not respect the format: CallerID: “Some Name” <1234> +- wait_time: Seconds to wait for an answer. Default is 45. +- max_retries: Number of retries before failing (not including the initial attempt, e.g. 0 = total of 1 attempt to make the call). Default is 0. +- retry_time: Seconds between retries, Don’t hammer an unavailable phone. The default is 300 (5 min). +- account: Set the account code to use. +- application: Asterisk Application to run (use instead of specifying context, extension and priority). +- data: The options to be passed to application. +- context: Context in extensions.conf +- extension: Extension definition in extensions.conf +- priority: Priority of extension to start with. +- set_var: Set of variables to set in url query format. +- archive: Yes/No – Move to subdir “outgoing_done” with “Status: value”, where value can be Completed, Expired or Failed. +- schedule: Schedule call for a later date/time. Can be natrual language input as parsed by https://github.com/olebedev/when \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..5ebf235 --- /dev/null +++ b/config.go @@ -0,0 +1,87 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "log" + "os" + "os/user" + "path/filepath" +) + +// Reference from: +// https://wiki.asterisk.org/wiki/display/AST/Asterisk+Call+Files + +// Config all configurations for this application. +type Config struct { + HTTPBind string `json:"http_bind"` + HTTPPort uint `json:"http_port"` + HTTPDebug bool `json:"http_debug"` + HTTPSystemDSocket bool `json:"http_systemd_socket"` + AsteriskSpoolDir string `json:"asterisk_spool_dir"` + DefaultChannel string `json:"default_channel"` + DefaultCallerId string `json:"default_caller_id"` + DefaultWaitTime uint64 `json:"default_wait_time"` // 5 seconds per ring. + DefaultMaxRetries uint64 `json:"default_max_retries"` + DefaultRetryTime uint64 `json:"default_retry_time"` + DefaultAccount string `json:"default_account"` + DefaultApplication string `json:"default_application"` + DefaultData string `json:"default_data"` + PreventAPIApplication bool `json:"prevent_api_application"` // For security, prevent applications from being executed via API call. + DefaultContext string `json:"default_context"` + DefaultExtension string `json:"default_extension"` + DefaultPriority string `json:"default_priority"` + DefaultSetVar map[string]string `json:"default_set_var"` + DefaultArchive bool `json:"default_archive"` + APIToken string `json:"api_token"` +} + +// ReadConfig read the configuration file into the config structure of the app. +func (a *App) ReadConfig() { + // Get our current user for use in determining the home path. + usr, err := user.Current() + if err != nil { + log.Fatal(err) + } + + // Different configuration file paths. + localConfig, _ := filepath.Abs("./config.json") + homeDirConfig := usr.HomeDir + "/.config/asterisk-outgoing-call-api/config.json" + etcConfig := "/etc/asterisk/outgoing-call-api.json" + + // Store defaults first. + app.config = Config{ + HTTPPort: 9747, + HTTPDebug: false, + HTTPSystemDSocket: false, + AsteriskSpoolDir: "/var/spool/asterisk", + PreventAPIApplication: true, + DefaultArchive: false, + } + + // Determine which config file to use. + var configFile string + if _, err := os.Stat(app.flags.ConfigPath); err == nil && app.flags.ConfigPath != "" { + configFile = app.flags.ConfigPath + } else if _, err := os.Stat(localConfig); err == nil { + configFile = localConfig + } else if _, err := os.Stat(homeDirConfig); err == nil { + configFile = homeDirConfig + } else if _, err := os.Stat(etcConfig); err == nil { + configFile = etcConfig + } else { + log.Fatal("Unable to find a configuration file.") + } + + // Read the config file. + jsonFile, err := ioutil.ReadFile(configFile) + if err != nil { + log.Fatalf("Error reading JSON file: %v\n", err) + } + + // Parse the config file into the configuration structure. + err = json.Unmarshal(jsonFile, &app.config) + if err != nil { + log.Fatalf("Error parsing JSON file: %v\n", err) + } +} diff --git a/example.call b/example.call new file mode 100644 index 0000000..648acfe --- /dev/null +++ b/example.call @@ -0,0 +1,6 @@ +Channel: pjsip/103 +WaitTime: 15 +Context: Phone-Ring-Dummy-Answer +Extension: talk +Priority: 1 +Archive: no \ No newline at end of file diff --git a/extension.conf b/extension.conf new file mode 100644 index 0000000..7ef9a26 --- /dev/null +++ b/extension.conf @@ -0,0 +1,4 @@ +[Phone-Ring-Dummy-Answer] +exten => talk,1,Answer() + same => n,Playback(all-your-base) + same => n,Hangup() \ No newline at end of file diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..5623303 --- /dev/null +++ b/flags.go @@ -0,0 +1,45 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +// Flags are command line tick options. +type Flags struct { + ConfigPath string + HTTPBind string + HTTPPort uint +} + +// Init configures the golang flags and parses the command line provided options. +func (f *Flags) Init() { + flag.Usage = func() { + fmt.Printf("asterisk-outgoing-call-api: Make an outgoing call via an API call.\n\nUsage:\n") + flag.PrintDefaults() + } + + var printVersion bool + flag.BoolVar(&printVersion, "v", false, "Print version") + + var usage string + usage = "Load configuration from file." + flag.StringVar(&f.ConfigPath, "config", "", usage) + flag.StringVar(&f.ConfigPath, "c", "", usage+" (shorthand)") + + usage = "Bind address for http server" + flag.StringVar(&f.HTTPBind, "http-bind", "", usage) + flag.StringVar(&f.HTTPBind, "b", "", usage+" (shorthand)") + + usage = "Bind port for http server" + flag.UintVar(&f.HTTPPort, "http-port", 0, usage) + flag.UintVar(&f.HTTPPort, "p", 0, usage+" (shorthand)") + + flag.Parse() + + if printVersion { + fmt.Println("asterisk-outgoing-call-api: 0.1") + os.Exit(0) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..63c6653 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/GRMrGecko/asterisk-outgoing-call-api + +go 1.16 + +require ( + github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf + github.com/olebedev/when v0.0.0-20190311101825-c3b538a97254 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..952d5ef --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE= +github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/olebedev/when v0.0.0-20190311101825-c3b538a97254 h1:JYoQR67E1vv1WGoeW8DkdFs7vrIEe/5wP+qJItd5tUE= +github.com/olebedev/when v0.0.0-20190311101825-c3b538a97254/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/http.go b/http.go new file mode 100644 index 0000000..74876b4 --- /dev/null +++ b/http.go @@ -0,0 +1,357 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "math/rand" + "net/http" + "net/url" + "os" + "os/signal" + "path" + "strconv" + "strings" + "syscall" + "time" + + "github.com/coreos/go-systemd/activation" + "github.com/olebedev/when" + "github.com/olebedev/when/rules/common" + "github.com/olebedev/when/rules/en" +) + +// HTTPServer the http server structure. +type HTTPServer struct { +} + +// Common strings. +const ( + APIOK = "ok" + APIERR = "error" +) + +// APIGeneralResp General response to API requests. +type APIGeneralResp struct { + Status string `json:"status"` + Error string `json:"error"` +} + +// JSONResponse Takes a golang structure and converts it to a JSON object for response. +func (s *HTTPServer) JSONResponse(w http.ResponseWriter, resp interface{}) { + // Encode response as json. + js, err := json.Marshal(resp) + if err != nil { + // Error should not happen normally... + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + // If no err, we can set content type header and send response. + w.Header().Set("Content-Type", "application/json") + w.Write(js) + w.Write([]byte{'\n'}) +} + +// APISendGeneralResp Send a standard response. +func (s *HTTPServer) APISendGeneralResp(w http.ResponseWriter, status, err string) { + resp := APIGeneralResp{} + resp.Status = status + resp.Error = err + s.JSONResponse(w, resp) +} + +// registerHandlers HTTP server handlers. +func (s *HTTPServer) registerHandlers(r *http.ServeMux) { + // For this project, we only handle requests to /. + r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // Parse form data. + err := r.ParseMultipartForm(32 << 20) + if err == http.ErrNotMultipart { + err = r.ParseForm() + } + if err != nil { + fmt.Println(err) + s.APISendGeneralResp(w, APIERR, "Bad request") + return + } + + // Verify we are authorized. + if r.Form.Get("token") != app.config.APIToken { + s.APISendGeneralResp(w, APIERR, "Unauthorized") + return + } + + // Get call details. + channel := r.Form.Get("channel") + if channel == "" { + channel = app.config.DefaultChannel + } + + callerId := r.Form.Get("caller_id") + if callerId == "" { + callerId = app.config.DefaultCallerId + } + + waitTime, err := strconv.ParseUint(r.Form.Get("wait_time"), 10, 64) + if err != nil { + waitTime = app.config.DefaultWaitTime + } + + maxRetries, err := strconv.ParseUint(r.Form.Get("max_retries"), 10, 64) + if err != nil { + maxRetries = app.config.DefaultMaxRetries + } + + retryTime, err := strconv.ParseUint(r.Form.Get("retry_time"), 10, 64) + if err != nil { + retryTime = app.config.DefaultRetryTime + } + + account := r.Form.Get("account") + if account == "" { + account = app.config.DefaultCallerId + } + + application := r.Form.Get("application") + if application == "" || app.config.PreventAPIApplication { + application = app.config.DefaultApplication + } + + data := r.Form.Get("data") + if data == "" || app.config.PreventAPIApplication { + data = app.config.DefaultData + } + + context := r.Form.Get("context") + if context == "" { + context = app.config.DefaultContext + } + + extension := r.Form.Get("extension") + if context == "" { + extension = app.config.DefaultExtension + } + + priority := r.Form.Get("priority") + if context == "" { + priority = app.config.DefaultPriority + } + + setVar := make(map[string]string) + parsed, err := url.ParseQuery(r.Form.Get("set_var")) + if err != nil { + setVar = app.config.DefaultSetVar + } else { + for key, value := range parsed { + setVar[key] = value[0] + } + } + + archiveVal := strings.ToLower(r.Form.Get("archive")) + archive := false + if archiveVal == "true" || archiveVal == "yes" { + archive = true + } else if archiveVal != "false" && archiveVal != "no" { + archive = app.config.DefaultArchive + } + + schedule := r.Form.Get("schedule") + + if channel == "" || (application == "" && context == "") { + s.APISendGeneralResp(w, APIERR, "Required options not set") + return + } + + // Setup call file details. + outgoingCallName := "outgoing-call-" + strconv.Itoa(rand.Int()) + spoolFileName := path.Join(app.config.AsteriskSpoolDir, outgoingCallName) + outgoingFileName := path.Join(app.config.AsteriskSpoolDir, "outgoing", outgoingCallName) + + callFile, err := os.Create(spoolFileName) + if err != nil { + fmt.Println(err) + s.APISendGeneralResp(w, APIERR, "Unable to create call file") + return + } + + // Write call details. + callFile.WriteString("Channel: " + channel + "\n") + + if callerId != "" { + callFile.WriteString("Callerid: " + callerId + "\n") + } + + if waitTime != 0 { + callFile.WriteString("WaitTime: " + strconv.FormatUint(waitTime, 10) + "\n") + } + + if maxRetries != 0 { + callFile.WriteString("MaxRetries: " + strconv.FormatUint(maxRetries, 10) + "\n") + } + + if retryTime != 0 { + callFile.WriteString("RetryTime: " + strconv.FormatUint(retryTime, 10) + "\n") + } + + if account != "" { + callFile.WriteString("Account: " + account + "\n") + } + + if application != "" { + callFile.WriteString("Application: " + application + "\n") + } + + if data != "" { + callFile.WriteString("Data: " + data + "\n") + } + + if context != "" { + callFile.WriteString("Context: " + context + "\n") + } + + if extension != "" { + callFile.WriteString("Extension: " + extension + "\n") + } + + if priority != "" { + callFile.WriteString("Priority: " + priority + "\n") + } + + for key, value := range setVar { + callFile.WriteString("Setvar: " + key + "=" + value + "\n") + } + + if archive { + callFile.WriteString("Archive: yes\n") + } else { + callFile.WriteString("Archive: no\n") + } + + callFile.Close() + + if schedule != "" { + now := time.Now() + w := when.New(nil) + w.Add(en.All...) + w.Add(common.All...) + parsedTime, _ := w.Parse(schedule, now) + if parsedTime == nil { + parsedTime = new(when.Result) + parsedTime.Time = now + } + + os.Chtimes(spoolFileName, parsedTime.Time, parsedTime.Time) + } + + // Add call to the outgoing call queue. + err = os.Rename(spoolFileName, outgoingFileName) + if err != nil { + fmt.Println(err) + s.APISendGeneralResp(w, APIERR, "Unable to move call file into outgoing directory") + return + } + + // Send final response. + s.APISendGeneralResp(w, APIOK, "") + }) +} + +func HTTPServe() { + // Used to reset the app quit timeout for systemd sockets. + var timeoutReset chan struct{} + + // Create the server. + httpServer := new(HTTPServer) + app.httpServer = httpServer + + // Setup the handlers. + r := http.NewServeMux() + httpServer.registerHandlers(r) + + // The http server handler will be the mux router by default. + var handler http.Handler + handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if app.config.HTTPSystemDSocket { + timeoutReset <- struct{}{} + } + if app.config.HTTPDebug { + log.Println(req.Method + " " + req.URL.String()) + } + r.ServeHTTP(w, req) + }) + + // Determine if we're using a systemd socket activation or just a standard listen. + if app.config.HTTPSystemDSocket { + done := make(chan struct{}) + quit := make(chan os.Signal, 1) + timeoutReset = make(chan struct{}) + + // On signal, gracefully shut down the server and wait 5 + // seconds for current connection to stop. + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + // Pull existing listener from systemd. + listeners, err := activation.Listeners() + if err != nil { + log.Panicf("Cannot retrieve listeners: %v", err) + } + + // If we already have a asterisk-outgoing-call-api running, then we shouldn't start... + if len(listeners) != 1 { + log.Panicf("Unexpected number of socket activation (%d != 1)", len(listeners)) + } + + server := &http.Server{ + Handler: handler, + } + + // Upon signal, close out existing connection and quit. + go func() { + <-quit + log.Println("Server is shutting down") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + server.SetKeepAlivesEnabled(false) + if err := server.Shutdown(ctx); err != nil { + log.Panicf("Cannot gracefully shut down the server: %v", err) + } + close(done) + }() + + // 30 minute time out if no connection is received. + go func() { + for { + select { + case <-timeoutReset: + case <-time.After(30 * time.Minute): + close(quit) + } + } + }() + + // Listen on existing systemd socket. + server.Serve(listeners[0]) + + // Wait for existing connections befor exiting. + <-done + } else { + // Get the configuration. + httpBind := app.config.HTTPBind + httpPort := app.config.HTTPPort + if app.flags.HTTPBind != "" { + httpBind = app.flags.HTTPBind + } + if app.flags.HTTPPort != 0 { + httpPort = app.flags.HTTPPort + } + + // Start the server. + log.Println("Starting the http server on port", httpPort) + err := http.ListenAndServe(fmt.Sprintf("%s:%d", httpBind, httpPort), handler) + if err != nil { + log.Fatal(err) + } + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..eacb562 --- /dev/null +++ b/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "math/rand" + "time" +) + +// App is the standard structure that allows different parts of the application to access common parameters/configuration. +type App struct { + flags *Flags + httpServer *HTTPServer + config Config +} + +var app *App + +func main() { + // We use rand for file naming, best set seed at start. + rand.Seed(time.Now().UnixNano()) + + app = new(App) + app.flags = new(Flags) + app.flags.Init() + app.ReadConfig() + + HTTPServe() +}