first commit

This commit is contained in:
James Coleman 2021-05-13 18:05:41 -05:00
commit b7ff98764b
11 changed files with 591 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
config.json

19
License.txt Normal file
View File

@ -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.

21
README.md Normal file
View File

@ -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, Dont 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

87
config.go Normal file
View File

@ -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)
}
}

6
example.call Normal file
View File

@ -0,0 +1,6 @@
Channel: pjsip/103
WaitTime: 15
Context: Phone-Ring-Dummy-Answer
Extension: talk
Priority: 1
Archive: no

4
extension.conf Normal file
View File

@ -0,0 +1,4 @@
[Phone-Ring-Dummy-Answer]
exten => talk,1,Answer()
same => n,Playback(all-your-base)
same => n,Hangup()

45
flags.go Normal file
View File

@ -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)
}
}

8
go.mod Normal file
View File

@ -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
)

16
go.sum Normal file
View File

@ -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=

357
http.go Normal file
View File

@ -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)
}
}
}

27
main.go Normal file
View File

@ -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()
}