From b7ff98764bcfc076a2130bbf9c9a13061661a77d Mon Sep 17 00:00:00 2001 From: James Coleman Date: Thu, 13 May 2021 18:05:41 -0500 Subject: [PATCH] first commit --- .gitignore | 1 + License.txt | 19 +++ README.md | 21 +++ config.go | 87 ++++++++++++ example.call | 6 + extension.conf | 4 + flags.go | 45 +++++++ go.mod | 8 ++ go.sum | 16 +++ http.go | 357 +++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 27 ++++ 11 files changed, 591 insertions(+) create mode 100644 .gitignore create mode 100644 License.txt create mode 100644 README.md create mode 100644 config.go create mode 100644 example.call create mode 100644 extension.conf create mode 100644 flags.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 http.go create mode 100644 main.go 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() +}