diff --git a/README.md b/README.md index f7a5dd9..a25caf0 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,198 @@ To get usage of all parameters just run `indihub-agent -help`. The latest `indihub-agent` release can be downloaded from [releases](https://github.com/indihub-space/agent/releases) or [indihub.space](https://indihub.space) Web-site. +## API + +There is an API-server running as part of `indihub-agent` and listening on port `:2020` (or on port specified via `-api-port=N` parameter) which provides two different APIs to control or use `indihub-agent`: + +- RESTful API to get status or switch modes of the the agent +- Websocket API to control equipment via websocket-connections + +By default API-server works over HTTP-protocol. You can switch it to work over TLS by providing `-api-tls` parameter. This will make `indihub-agent` to generate self-signed CA and certificate. + +### RESTful API + +You can use this simple RESTful API to control `indihub-agent`. + +NOTE: CORS protection can be specified with comma-separated list of allowed origins via agent parameter: + +`-api-origins=host1,host2,hostN`. + +Also, all examples assume `indihub-agent` is running on host `raspberrypi.local`. + +#### 1. Get indihub-agent status (public) + +`curl -X GET http://raspberrypi.local:2020/status` + +Response example: +```json +{ + "indiProfile": "NEO-remote", + "indiServer": "raspberrypi.local:7624", + "mode": "solo", + "phd2Server": "", + "status": "running", + "supportedModes": [ + "solo", + "share", + "robotic" + ], + "version": "1.0.3" +} +``` + +#### 2. Restart indihub-agent current mode (public) + +`curl -X GET "http://raspberrypi.local:2020/status"` + +#### 3. Switch indihub-agent mode (protected via token) + +You need to do HTTP-request `POST "http://indihub-agent-host:2020/mode/{mode}"` specifying required mode in a `mode` URI-parameter and supplying your token in `Authorization` header, i.e.: + +`curl -X POST "http://raspberrypi.local:2020/mode/solo" -H "Authorization: Bearer cca13ac2951efd6d912ead20a7ab4882"` + +Response will have status of agent running in new mode: + +```json +{ + "indiProfile": "NEO-remote", + "indiServer": "raspberrypi.local:7624", + "mode": "share", + "phd2Server": "", + "publicEndpoints": [ + { + "name": "INDI-Server", + "addr": "node-1.indihub.io:55642" + } + ], + "status": "running", + "supportedModes": [ + "share", + "robotic", + "solo" + ], + "version": "1.0.3" +} +``` + +### Websocket API + +You can use `indihub-agent` to control your equipment via Websocket API, i.e. from your Web-app open int the Web-browser. + +NOTE: Websocket upgrade-requests are allowed only from origins specified with comma-separated list via agent parameter: + +`-api-origins=host1,host2,hostN` + +#### 1. Open WS-connection to INDI-server (protected via token) + +To establish new WS-connection to INDI-server you will need to use URL with format: + +`ws://raspberrypi.local:2020/websocket/indiserver?token=cca13ac2951efd6d912ead20a7ab4882` + +NOTE: we connect to `raspberrypi.local:2020` which is `indihub-agent` API-server and we provide token via `token` query string parameter. + +This will open WS-connection to you your equipment via `indihub-agent` API-server where all outgoing messages will be INDI-protocol commands and all incoming messages will be INDI-protocol replies from your equipment. + +#### 2. Message format for INDI-server Websocket connection + +All commands are expected to be in JSON-format where INDI-command XML attributes translate to fields with `attr_`-prefix. + +I.e. to send this INDI XML-command over WS-connection: + +```xml + +``` + +You will need to send message over WS-connection in JSON-format: +```json +{ + "getProperties": { + "attr_version":"1.7" + } +} +``` + +The INDI-protocol responses will be converted from XML to JSON-messages and sent over WS-connection as messages. + +I.e. this INDI-response about telescopes installed on the mount in XML-format: +```xml + + +120 + + +600 + + +50 + + +162 + + +``` + +Will be converted in to JSON-format like this: + +```json +{ + "defNumberVector": { + "attr_device":"iOptron CEM25", + "attr_group":"Options", + "attr_label":"Scope Properties", + "attr_name":"TELESCOPE_INFO", + "attr_perm":"rw", + "attr_state":"Ok", + "attr_timeout":60, + "attr_timestamp":"2020-02-20T21:52:23", + "defNumber": [ + { + "#text":120, + "attr_format":"%g", + "attr_label":"Aperture (mm)", + "attr_max":5000, + "attr_min":10, + "attr_name":"TELESCOPE_APERTURE", + "attr_step":0 + }, + { + "#text":600, + "attr_format":"%g", + "attr_label":"Focal Length (mm)", + "attr_max":10000, + "attr_min":10, + "attr_name":"TELESCOPE_FOCAL_LENGTH", + "attr_step":0 + }, + { + "#text":50, + "attr_format":"%g", + "attr_label":"Guider Aperture (mm)", + "attr_max":5000, + "attr_min":10, + "attr_name":"GUIDER_APERTURE", + "attr_step":0 + }, + { + "#text":162, + "attr_format":"%g", + "attr_label":"Guider Focal Length (mm)", + "attr_max":10000, + "attr_min":10, + "attr_name":"GUIDER_FOCAL_LENGTH", + "attr_step":0 + } + ] + } +} +``` + +Please NOTE: + +- XML element attributes get converted into JSON-fields with `attr_` prefix +- XML element value get converted into JSON-field with name `#text` +- vector like child-elements get converted into JSON-arrays + ## Building indihub-agent You will need to install [Golang](https://golang.org/dl/). diff --git a/apiserver/server.go b/apiserver/server.go new file mode 100644 index 0000000..7850c96 --- /dev/null +++ b/apiserver/server.go @@ -0,0 +1,363 @@ +package apiserver + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/indihub-space/agent/version" + + "github.com/gorilla/websocket" + "github.com/labstack/echo" + "github.com/labstack/echo/middleware" + elog "github.com/labstack/gommon/log" + + "github.com/indihub-space/agent/lib" + "github.com/indihub-space/agent/logutil" +) + +var allowedOrigins = map[string]bool{ + "indihub.space": true, + "app.indihub.space": true, + "kids.indihub.space": true, +} + +// AgentMode provides interface to operate with agent from API-server +type AgentMode interface { + Start() + Stop() + GetStatus() map[string]interface{} +} + +type APIServer struct { + token string + indiServerAddr string + phd2ServerAddr string + port uint64 + isTLS bool + origins string + + e *echo.Echo + upgrader websocket.Upgrader + connList []net.Conn + + indiProfile string + currMode string + agentModes map[string]AgentMode +} + +func NewAPIServer(token string, indiServerAddr string, phd2ServerAddr string, port uint64, isTLS bool, origins string, + currMode string, indiProfile string, agentModes map[string]AgentMode) *APIServer { + + apiServer := &APIServer{ + token: token, + indiServerAddr: indiServerAddr, + phd2ServerAddr: phd2ServerAddr, + port: port, + isTLS: isTLS, + e: echo.New(), + upgrader: websocket.Upgrader{}, + connList: []net.Conn{}, + indiProfile: indiProfile, + currMode: currMode, + agentModes: agentModes, + } + + if logutil.IsDev { + allowedOrigins["localhost"] = true + } + + // add optional additional origins + for _, orig := range strings.Split(origins, ",") { + allowedOrigins[strings.TrimSpace(orig)] = true + } + + // allow WS connections only from number of domains + apiServer.upgrader.CheckOrigin = func(r *http.Request) bool { + origin := r.Header["Origin"] + if len(origin) == 0 { + return false + } + u, err := url.Parse(origin[0]) + if err != nil { + return false + } + host, _, err := net.SplitHostPort(u.Host) + if err != nil { + return false + } + return allowedOrigins[host] + } + + return apiServer +} + +func (s *APIServer) newIndiConnection(c echo.Context) error { + // upgrade to WS connection + ws, err := s.upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer ws.Close() + + // open connection to INDI-Server + conn, err := net.Dial("tcp", s.indiServerAddr) + if err != nil { + return err + } + + // add to connection list + s.connList = append(s.connList, conn) + + // read messages from INDI-server and write them to WS + go func(indiConn net.Conn, wsConn *websocket.Conn) { + buf := make([]byte, lib.INDIServerMaxSendMsgSize, lib.INDIServerMaxSendMsgSize) + xmlFlattener := lib.NewXmlFlattener() + for { + // read from INDI-server + n, err := indiConn.Read(buf) + if err != nil { + indiConn.Close() + return + } + + jsonMessages := xmlFlattener.ConvertChunkToJSON(buf[:n]) + + // Write to WS + for _, m := range jsonMessages { + err = wsConn.WriteMessage(websocket.TextMessage, m) + if err != nil { + indiConn.Close() + return + } + } + } + }(conn, ws) + + // read messages from WS and write them to INDI-server + xmlFlattener := lib.NewXmlFlattener() + for { + // Read from WS + _, msg, err := ws.ReadMessage() + if err != nil { + conn.Close() + return err + } + + xmlMsg, err := xmlFlattener.ConvertJSONToXML(msg) + if err != nil { + log.Printf("could not convert json '%s' to xml: %s", string(msg), err) + continue + } + + // write to INDI server + _, err = conn.Write(xmlMsg) + if err != nil { + conn.Close() + return err + } + } +} + +func (s *APIServer) newPHD2Connection(c echo.Context) error { + return nil +} + +func (s *APIServer) getRestart(c echo.Context) error { + // restart current mode + if curAgentMode, ok := s.agentModes[s.currMode]; ok { + curAgentMode.Stop() + time.Sleep(1 * time.Second) + curAgentMode.Start() + } + + c.JSONPretty(http.StatusOK, s.agentStatus(), " ") + + return nil +} + +func (s *APIServer) getStatus(c echo.Context) error { + c.JSONPretty(http.StatusOK, s.agentStatus(), " ") + + return nil +} + +func (s *APIServer) changeMode(c echo.Context) error { + newMode := c.Param("new_mode") + + // don't do anything if agent is laready in this mode + if newMode == s.currMode { + c.JSONPretty(http.StatusOK, s.agentStatus(), " ") + return nil + } + + // get agent new mode + newAgentMode, ok := s.agentModes[newMode] + if !ok { + c.JSON( + http.StatusBadRequest, + map[string]interface{}{ + "message": "unknown indihub-agent mode: " + newMode, + }, + ) + return nil + } + + // stop current mode + if curAgentMode, ok := s.agentModes[s.currMode]; ok { + curAgentMode.Stop() + } + + time.Sleep(1 * time.Second) + + // start agent in new mode + s.currMode = newMode + newAgentMode.Start() + + c.JSONPretty(http.StatusOK, s.agentStatus(), " ") + + return nil +} + +func (s *APIServer) agentStatus() map[string]interface{} { + agentStatus := map[string]interface{}{ + "version": version.AgentVersion, + "mode": s.currMode, + "indiProfile": s.indiProfile, + "indiServer": s.indiServerAddr, + "phd2Server": s.phd2ServerAddr, + } + + supportedModes := make([]string, 0, len(s.agentModes)) + for key := range s.agentModes { + supportedModes = append(supportedModes, key) + } + agentStatus["supportedModes"] = supportedModes + + if agentMode, ok := s.agentModes[s.currMode]; ok { + for key, val := range agentMode.GetStatus() { + agentStatus[key] = val + } + } + + return agentStatus +} + +func (s *APIServer) Start() { + s.e.HideBanner = true + s.e.HidePort = true + + if !logutil.IsDev { + s.e.Logger.SetLevel(elog.OFF) + } + + // setup middle-wares + + s.e.Use(middleware.Recover()) + + // set CORS in case browser decides to do pre-flight OPTIONS request + // default ones + allowOrigins := []string{} + for orig := range allowedOrigins { + allowOrigins = append(allowOrigins, "http://"+orig) + allowOrigins = append(allowOrigins, "https://"+orig) + } + // optional ones + for _, orig := range strings.Split(s.origins, ",") { + allowOrigins = append(allowOrigins, "http://"+strings.TrimSpace(orig)) + allowOrigins = append(allowOrigins, "https://"+strings.TrimSpace(orig)) + } + // add localhost for dev-mode + if logutil.IsDev { + allowOrigins = append(allowOrigins, "http://localhost:5000") + } + s.e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: allowOrigins, + AllowMethods: []string{ + http.MethodGet, + http.MethodPost, + }, + })) + + // setup routing for WS and RESTful APIs + + // protected WS-API + wsGroup := s.e.Group( + "/websocket", + // set auth middleware + middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{ + KeyLookup: "query:token", + Validator: func(token string, eCtx echo.Context) (b bool, err error) { + return token == s.token, nil + }, + }), + ) + wsGroup.GET("/indiserver", s.newIndiConnection) + wsGroup.GET("/phd2server", s.newPHD2Connection) + + // protected RESTful API + s.e.POST( + "/mode/:new_mode", + s.changeMode, + // set auth middleware + middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{ + KeyLookup: "header:Authorization", + Validator: func(token string, eCtx echo.Context) (b bool, err error) { + return token == s.token, nil + }, + }), + ) + + // public RESTful API + s.e.GET("/status", s.getStatus) + s.e.GET("/restart", s.getRestart) + + // start agent in a required mode + agentMode, ok := s.agentModes[s.currMode] + if !ok { + log.Println("unknown agent mode:", s.currMode) + return + } + agentMode.Start() + + // check if we are running TLS + if s.isTLS { + // generate self-signed cert to serve WS over TLS + keyFile, certFile, err := getSelfSignedCert() + if err != nil { + log.Println("could not start API-server, self-signed certificate generating failed:", err) + return + } + // start HTTP/WS API server over TLS + err = s.e.StartTLS(fmt.Sprintf(":%d", s.port), certFile, keyFile) + if err != nil && err != http.ErrServerClosed { + log.Println("API-server error:", err) + } else { + log.Println("API-server was shutdown gracefully") + } + return + } + + // start HTTP/WS API server + err := s.e.Start(fmt.Sprintf(":%d", s.port)) + if err != nil && err != http.ErrServerClosed { + log.Println("API-server error:", err) + } else { + log.Println("API-server was shutdown gracefully") + } +} + +func (s *APIServer) Stop() { + if agentMode, ok := s.agentModes[s.currMode]; ok { + agentMode.Stop() + } + for _, conn := range s.connList { + conn.Close() + } + s.e.Shutdown(context.Background()) +} diff --git a/websockets/tls.go b/apiserver/tls.go similarity index 99% rename from websockets/tls.go rename to apiserver/tls.go index f821690..69dbd8c 100644 --- a/websockets/tls.go +++ b/apiserver/tls.go @@ -1,4 +1,4 @@ -package websockets +package apiserver import ( "crypto/ecdsa" diff --git a/lib/const.go b/lib/const.go index e2462bb..4a390f3 100644 --- a/lib/const.go +++ b/lib/const.go @@ -6,4 +6,8 @@ const ( INDIServerMaxRecvMsgSize = 49152 INDIServerMaxSendMsgSize = 2048 + + ModeSolo = "solo" + ModeShare = "share" + ModeRobotic = "robotic" ) diff --git a/main.go b/main.go index 05f4365..3a992ba 100644 --- a/main.go +++ b/main.go @@ -11,32 +11,25 @@ import ( "os" "os/signal" "runtime" - "sync" - "time" - "github.com/fatih/color" + "github.com/indihub-space/agent/apiserver" + "google.golang.org/grpc" "google.golang.org/grpc/credentials" _ "google.golang.org/grpc/encoding/gzip" "github.com/indihub-space/agent/config" - "github.com/indihub-space/agent/hostutils" "github.com/indihub-space/agent/lib" "github.com/indihub-space/agent/logutil" "github.com/indihub-space/agent/manager" "github.com/indihub-space/agent/proto/indihub" - "github.com/indihub-space/agent/proxy" + "github.com/indihub-space/agent/share" "github.com/indihub-space/agent/solo" "github.com/indihub-space/agent/version" - "github.com/indihub-space/agent/websockets" ) const ( - defaultWSPort uint64 = 2020 - - modeSolo = "solo" - modeShare = "share" - modeRobotic = "robotic" + defaultAPIPort uint64 = 2020 ) var ( @@ -47,10 +40,9 @@ var ( flagConfFile string flagSoloINDIServerAddr string flagCompress bool - flagWSServer bool - flagWSIsTLS bool - flagWSPort uint64 - flagWSOrigins string + flagAPITLS bool + flagAPIPort uint64 + flagAPIOrigins string flagMode string indiServerAddr string @@ -68,7 +60,7 @@ func init() { flag.StringVar( &flagMode, "mode", - modeSolo, + lib.ModeSolo, `indihub-agent mode (deafult value is "solo"), there four modes:\n solo - equipment sharing is not possible, you are connected to INDIHUB and contributing images share - you are sharing equipment with another INDIHUB user (agent will output connection info) @@ -112,35 +104,29 @@ robotic - equipment sharing is not possible, your equipment is controlled by IND "Name of INDI-profile to share via indihub", ) flag.BoolVar( - &flagWSServer, - "ws-server", - true, - "launch Websocket server to control equipment via Websocket API", - ) - flag.BoolVar( - &flagWSIsTLS, - "ws-tls", + &flagAPITLS, + "api-tls", false, - "serve web-socket over TLS with self-signed certificate", + "serve API-server over TLS with self-signed certificate", ) flag.Uint64Var( - &flagWSPort, - "ws-port", - defaultWSPort, - "port to start web socket-server on", + &flagAPIPort, + "api-port", + defaultAPIPort, + "port to start API-server on", ) flag.StringVar( - &flagWSOrigins, - "ws-origins", + &flagAPIOrigins, + "api-origins", "", - "comma-separated list of origins allowed to connect to WS-server", + "comma-separated list of origins allowed to connect to API-server", ) } func main() { flag.Parse() - if flagMode != modeSolo && flagMode != modeShare && flagMode != modeRobotic { + if flagMode != lib.ModeSolo && flagMode != lib.ModeShare && flagMode != lib.ModeRobotic { log.Fatalf("Unknown mode '%s' provided\n", flagMode) } @@ -239,9 +225,9 @@ func main() { Autoconnect: indiProfile.AutoConnect, }, Drivers: make([]*indihub.INDIDriver, len(indiDrivers)), - SoloMode: flagMode == modeSolo, + SoloMode: flagMode == lib.ModeSolo, IsPHD2: flagPHD2ServerAddr != "", - IsRobotic: flagMode == modeRobotic, + IsRobotic: flagMode == lib.ModeRobotic, IsBroadcast: false, AgentVersion: version.AgentVersion, Os: runtime.GOOS, @@ -287,6 +273,8 @@ func main() { log.Fatal(err) } log.Println("...OK") + // close grpc client connection at the very end + defer conn.Close() indiHubClient := indihub.NewINDIHubClient(conn) @@ -296,21 +284,7 @@ func main() { log.Fatal(err) } - log.Println("Current agent version:", version.AgentVersion) - log.Println("Latest agent version:", regInfo.AgentVersion) - - if version.AgentVersion < regInfo.AgentVersion { - yc := color.New(color.FgYellow) - yc.Println() - yc.Println(" ************************************************************") - yc.Println(" * WARNING: you version of agent is outdated! *") - yc.Println(" * *") - yc.Println(" * Please download the latest version from: *") - yc.Println(" * https://indihub.space/downloads *") - yc.Println(" * *") - yc.Println(" ************************************************************") - yc.Println(" ") - } + version.CheckAgentVersion(regInfo.AgentVersion) log.Printf("Access token: %s\n", regInfo.Token) log.Printf("Host session token: %s\n", regInfo.SessionIDPublic) @@ -325,203 +299,40 @@ func main() { } } - // start WS-server - wsServer := websockets.NewWsServer( + // prepare all modes + soloMode := solo.NewMode(indiHubClient, regInfo, indiServerAddr, ccdDrivers) + shareMode := share.NewMode(indiHubClient, regInfo, indiServerAddr, flagPHD2ServerAddr, lib.ModeShare) + roboticMode := share.NewMode(indiHubClient, regInfo, indiServerAddr, flagPHD2ServerAddr, lib.ModeRobotic) + + // start API-server + apiServer := apiserver.NewAPIServer( regInfo.Token, indiServerAddr, flagPHD2ServerAddr, - flagWSPort, - flagWSIsTLS, - flagWSOrigins, + flagAPIPort, + flagAPITLS, + flagAPIOrigins, + flagMode, + flagINDIProfile, + map[string]apiserver.AgentMode{ + lib.ModeSolo: soloMode, + lib.ModeShare: shareMode, + lib.ModeRobotic: roboticMode, + }, ) - go wsServer.Start() - // start session - switch flagMode { + go func() { + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, os.Interrupt, os.Kill) - case modeSolo: - // solo mode - equipment sharing is not available but host still sends all images to INDIHUB - log.Println("'solo' parameter was provided. Your session is in solo-mode: equipment sharing is not available") - log.Println("Starting INDIHUB agent in solo mode!") + <-sigint - soloClient, err := indiHubClient.SoloMode(context.Background()) - if err != nil { - log.Fatalf("Could not start agent in solo mode: %v", err) - } + log.Println("Stopping API-server gracefully") - soloAgent := solo.New( - indiServerAddr, - soloClient, - ccdDrivers, - ) + // close connections to local INDI-server + apiServer.Stop() + }() - go func() { - sigint := make(chan os.Signal, 1) - signal.Notify(sigint, os.Interrupt, os.Kill) - - <-sigint - - // stop WS-server - wsServer.Stop() - - log.Println("Closing INDIHUB solo-session") - - // close connections to local INDI-server and to INDI client - soloAgent.Close() - - time.Sleep(1 * time.Second) - - // close grpc client connection - conn.Close() - }() - - // start solo mode INDI-server tcp-proxy - wg := sync.WaitGroup{} - wg.Add(1) - go func() { - defer wg.Done() - soloAgent.Start(regInfo.SessionID, regInfo.SessionIDPublic) - }() - - wg.Wait() - - case modeShare, modeRobotic: - // main equipment sharing mode - if flagMode == modeRobotic { - log.Println("'robotic' parameter was provided. Your session is in robotic-mode: equipment sharing is not available") - } - // open INDI server tunnel - log.Println("Starting INDI-Server in the cloud...") - indiServTunnel, err := indiHubClient.INDIServer( - context.Background(), - ) - if err != nil { - log.Fatal(err) - } - log.Println("...OK") - - indiFilterConf := &hostutils.INDIFilterConfig{} // TODO: add reading config - indiFilter := hostutils.NewINDIFilter(indiFilterConf) - indiServerProxy := proxy.New("INDI-Server", indiServerAddr, indiServTunnel, indiFilter) - - // start PHD2 server proxy if specified - var phd2ServerProxy *proxy.TcpProxy - if flagPHD2ServerAddr != "" { - // open PHD2 server tunnel - log.Println("Starting PHD2-Server in the cloud...") - phd2ServTunnel, err := indiHubClient.PHD2Server( - context.Background(), - ) - if err != nil { - log.Fatal(err) - } - log.Println("...OK") - phd2ServerProxy = proxy.New("PHD2-Server", flagPHD2ServerAddr, phd2ServTunnel, nil) - } - - go func() { - sigint := make(chan os.Signal, 1) - signal.Notify(sigint, os.Interrupt, os.Kill) - - <-sigint - - // stop WS-server - wsServer.Stop() - - // close connections to tunnels - indiServTunnel.CloseSend() - if phd2ServerProxy != nil { - phd2ServerProxy.Tunnel.CloseSend() - } - - // close grpc client connection - conn.Close() - - // close connections to local INDI-server and PHD2-Server - indiServerProxy.Close() - if phd2ServerProxy != nil { - phd2ServerProxy.Close() - } - }() - - serverAddrChan := make(chan proxy.PublicServerAddr, 3) - - wg := sync.WaitGroup{} - - // INDI Server Proxy start - waitNum := 1 - wg.Add(1) - go func() { - defer wg.Done() - indiServerProxy.Start(serverAddrChan, regInfo.SessionID, regInfo.SessionIDPublic) - }() - - if flagPHD2ServerAddr != "" { - waitNum = 2 - wg.Add(1) - go func() { - defer wg.Done() - phd2ServerProxy.Start(serverAddrChan, regInfo.SessionID, regInfo.SessionIDPublic) - }() - } - - addrData := []proxy.PublicServerAddr{} - for i := 0; i < waitNum; i++ { - sAddr := <-serverAddrChan - addrData = append(addrData, sAddr) - } - - c := color.New(color.FgCyan) - gc := color.New(color.FgGreen) - yc := color.New(color.FgYellow) - rc := color.New(color.FgMagenta) - if flagMode != modeRobotic { - c.Println() - c.Println(" ************************************************************") - c.Println(" * INDIHUB public address list!! *") - c.Println(" ************************************************************") - c.Println(" ") - for _, sAddr := range addrData { - gc.Printf(" %s: %s\n", sAddr.Name, sAddr.Addr) - } - c.Println(" ") - c.Println(" ************************************************************") - c.Println() - c.Println(" Please provide your guest with this information:") - c.Println() - c.Println(" 1. Public address list from the above") - c.Println(" 2. Focal length and aperture of your main telescope") - c.Println(" 3. Focal length and aperture of your guiding telescope") - c.Println(" 4. Type of guiding you use: PHD2 or guiding via camera") - c.Println(" 5. Names of your imaging camera and guiding cameras") - c.Println() - yc.Println(" NOTE: These public addresses will be available ONLY until") - yc.Println(" agent is running! (Ctrl+C will stop the session)") - c.Println() - } else { - c.Println() - c.Println(" ************************************************************") - c.Println(" * INDIHUB robotic-session started!! *") - c.Println(" ************************************************************") - c.Println(" ") - } - - wg.Wait() - - c.Println() - c.Println(" ************************************************************") - c.Println(" * INDIHUB session finished!! *") - c.Println(" ************************************************************") - c.Println(" ") - if flagMode != modeRobotic { - for _, sAddr := range addrData { - rc.Printf(" %s: %s - CLOSED!!\n", sAddr.Name, sAddr.Addr) - } - } else { - c.Println(" * INDIHUB robotic-session finished. *") - c.Println(" * Thank you for your contribution! *") - } - c.Println(" ") - c.Println(" ************************************************************") - } + // start API-server and indihub-agent in the current mode + apiServer.Start() } diff --git a/proxy/proxy.go b/proxy/proxy.go index 0da0bf5..f141768 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -30,8 +30,8 @@ type TcpProxy struct { } type PublicServerAddr struct { - Name string - Addr string + Name string `json:"name"` + Addr string `json:"addr"` } func New(name string, addr string, tunnel INDIHubTunnel, filter *hostutils.INDIFilter) *TcpProxy { diff --git a/share/mode.go b/share/mode.go new file mode 100644 index 0000000..533e936 --- /dev/null +++ b/share/mode.go @@ -0,0 +1,178 @@ +package share + +import ( + "context" + "log" + + "github.com/fatih/color" + + "github.com/indihub-space/agent/hostutils" + "github.com/indihub-space/agent/lib" + "github.com/indihub-space/agent/proto/indihub" + "github.com/indihub-space/agent/proxy" +) + +type Mode struct { + indiHubClient indihub.INDIHubClient + regInfo *indihub.RegisterInfo + + indiServerAddr string + phd2ServerAddr string + + addrData []proxy.PublicServerAddr + + stopCh chan struct{} + status string + mode string +} + +func NewMode(indiHubClient indihub.INDIHubClient, regInfo *indihub.RegisterInfo, indiServerAddr string, phd2ServerAddr string, mode string) *Mode { + return &Mode{ + indiHubClient: indiHubClient, + regInfo: regInfo, + indiServerAddr: indiServerAddr, + phd2ServerAddr: phd2ServerAddr, + mode: mode, + addrData: []proxy.PublicServerAddr{}, + stopCh: make(chan struct{}, 1), + } +} + +func (m *Mode) Start() { + // main equipment sharing mode + if m.mode == lib.ModeRobotic { + log.Println("'robotic' parameter was provided. Your session is in robotic-mode: equipment sharing is not available") + } + // open INDI server tunnel + log.Println("Starting INDI-Server in the cloud...") + indiServTunnel, err := m.indiHubClient.INDIServer( + context.Background(), + ) + if err != nil { + log.Fatal(err) + } + log.Println("...OK") + + indiFilterConf := &hostutils.INDIFilterConfig{} // TODO: add reading config + indiFilter := hostutils.NewINDIFilter(indiFilterConf) + indiServerProxy := proxy.New("INDI-Server", m.indiServerAddr, indiServTunnel, indiFilter) + + // start PHD2 server proxy if specified + var phd2ServerProxy *proxy.TcpProxy + if m.phd2ServerAddr != "" { + // open PHD2 server tunnel + log.Println("Starting PHD2-Server in the cloud...") + phd2ServTunnel, err := m.indiHubClient.PHD2Server( + context.Background(), + ) + if err != nil { + log.Fatal(err) + } + log.Println("...OK") + phd2ServerProxy = proxy.New("PHD2-Server", m.phd2ServerAddr, phd2ServTunnel, nil) + } + + go func() { + <-m.stopCh + + log.Printf("Closing %s-session\n", m.mode) + + // close connections to tunnels + indiServTunnel.CloseSend() + if phd2ServerProxy != nil { + phd2ServerProxy.Tunnel.CloseSend() + } + + // close connections to local INDI-server and PHD2-Server + indiServerProxy.Close() + if phd2ServerProxy != nil { + phd2ServerProxy.Close() + } + }() + + serverAddrChan := make(chan proxy.PublicServerAddr, 3) + + // INDI Server Proxy start + go indiServerProxy.Start(serverAddrChan, m.regInfo.SessionID, m.regInfo.SessionIDPublic) + sAddr := <-serverAddrChan + if m.mode != lib.ModeRobotic { + m.addrData = append(m.addrData, sAddr) + } + + // PHD2 Server proxy start + if m.phd2ServerAddr != "" { + go phd2ServerProxy.Start(serverAddrChan, m.regInfo.SessionID, m.regInfo.SessionIDPublic) + sAddr := <-serverAddrChan + if m.mode != lib.ModeRobotic { + m.addrData = append(m.addrData, sAddr) + } + } + + c := color.New(color.FgCyan) + gc := color.New(color.FgGreen) + yc := color.New(color.FgYellow) + if m.mode != lib.ModeRobotic { + c.Println() + c.Println(" ************************************************************") + c.Println(" * INDIHUB public address list!! *") + c.Println(" ************************************************************") + c.Println(" ") + for _, sAddr := range m.addrData { + gc.Printf(" %s: %s\n", sAddr.Name, sAddr.Addr) + } + c.Println(" ") + c.Println(" ************************************************************") + c.Println() + c.Println(" Please provide your guest with this information:") + c.Println() + c.Println(" 1. Public address list from the above") + c.Println(" 2. Focal length and aperture of your main telescope") + c.Println(" 3. Focal length and aperture of your guiding telescope") + c.Println(" 4. Type of guiding you use: PHD2 or guiding via camera") + c.Println(" 5. Names of your imaging camera and guiding cameras") + c.Println() + yc.Println(" NOTE: These public addresses will be available ONLY until") + yc.Println(" agent is running! (Ctrl+C will stop the session)") + c.Println() + } else { + c.Println() + c.Println(" ************************************************************") + c.Println(" * INDIHUB robotic-session started!! *") + c.Println(" ************************************************************") + c.Println(" ") + } + + m.status = "running" +} + +func (m *Mode) Stop() { + m.status = "stopped" + m.stopCh <- struct{}{} + + c := color.New(color.FgCyan) + rc := color.New(color.FgMagenta) + c.Println() + c.Println(" ************************************************************") + c.Println(" * INDIHUB session finished!! *") + c.Println(" ************************************************************") + c.Println(" ") + if m.mode != lib.ModeRobotic { + for _, sAddr := range m.addrData { + rc.Printf(" %s: %s - CLOSED!!\n", sAddr.Name, sAddr.Addr) + } + } else { + c.Println(" * INDIHUB robotic-session finished. *") + c.Println(" * Thank you for your contribution! *") + } + c.Println(" ") + c.Println(" ************************************************************") + + m.addrData = []proxy.PublicServerAddr{} +} + +func (m *Mode) GetStatus() map[string]interface{} { + return map[string]interface{}{ + "status": m.status, + "publicEndpoints": m.addrData, + } +} diff --git a/solo/mode.go b/solo/mode.go new file mode 100644 index 0000000..238864c --- /dev/null +++ b/solo/mode.go @@ -0,0 +1,70 @@ +package solo + +import ( + "context" + "log" + + "github.com/indihub-space/agent/proto/indihub" +) + +type Mode struct { + indiServerAddr string + indiHubClient indihub.INDIHubClient + regInfo *indihub.RegisterInfo + ccdDrivers []string + + stopCh chan struct{} + status string +} + +func NewMode(indiHubClient indihub.INDIHubClient, regInfo *indihub.RegisterInfo, indiServerAddr string, ccdDrivers []string) *Mode { + return &Mode{ + indiServerAddr: indiServerAddr, + indiHubClient: indiHubClient, + regInfo: regInfo, + ccdDrivers: ccdDrivers, + stopCh: make(chan struct{}, 1), + } +} + +func (s *Mode) Start() { + // solo mode - equipment sharing is not available but host still sends all images to INDIHUB + log.Println("'solo' parameter was provided. Your session is in solo-mode: equipment sharing is not available") + log.Println("Starting INDIHUB agent in solo mode!") + + soloClient, err := s.indiHubClient.SoloMode(context.Background()) + if err != nil { + log.Fatalf("Could not start agent in solo mode: %v", err) + } + + soloAgent := New( + s.indiServerAddr, + soloClient, + s.ccdDrivers, + ) + + go func() { + <-s.stopCh + log.Println("Closing INDIHUB solo-session") + // close connections to local INDI-server + soloAgent.Close() + }() + + // start agent in solo-mode + go func() { + soloAgent.Start(s.regInfo.SessionID, s.regInfo.SessionIDPublic) + }() + + s.status = "running" +} + +func (s *Mode) Stop() { + s.status = "stopped" + s.stopCh <- struct{}{} +} + +func (s *Mode) GetStatus() map[string]interface{} { + return map[string]interface{}{ + "status": s.status, + } +} diff --git a/version/version.go b/version/version.go index c4746eb..ff3fa0b 100644 --- a/version/version.go +++ b/version/version.go @@ -1,3 +1,27 @@ package version +import ( + "log" + + "github.com/fatih/color" +) + var AgentVersion = "1.0.2" + +func CheckAgentVersion(latestVer string) { + log.Println("Current agent version:", AgentVersion) + log.Println("Latest agent version:", latestVer) + + if AgentVersion < latestVer { + yc := color.New(color.FgYellow) + yc.Println() + yc.Println(" ************************************************************") + yc.Println(" * WARNING: you version of agent is outdated! *") + yc.Println(" * *") + yc.Println(" * Please download the latest version from: *") + yc.Println(" * https://indihub.space/downloads *") + yc.Println(" * *") + yc.Println(" ************************************************************") + yc.Println(" ") + } +} diff --git a/websockets/server.go b/websockets/server.go deleted file mode 100644 index 213c186..0000000 --- a/websockets/server.go +++ /dev/null @@ -1,221 +0,0 @@ -package websockets - -import ( - "context" - "fmt" - "log" - "net" - "net/http" - "net/url" - "strings" - - "github.com/gorilla/websocket" - "github.com/labstack/echo" - "github.com/labstack/echo/middleware" - elog "github.com/labstack/gommon/log" - - "github.com/indihub-space/agent/lib" - "github.com/indihub-space/agent/logutil" -) - -var allowedOrigins = map[string]bool{ - "indihub.space": true, - "app.indihub.space": true, - "kids.indihub.space": true, -} - -type WsServer struct { - token string - indiServerAddr string - phd2ServerAddr string - wsPort uint64 - isTLS bool - origins string - - e *echo.Echo - upgrader websocket.Upgrader - connList []net.Conn -} - -func NewWsServer(token string, indiServerAddr string, phd2ServerAddr string, wsPort uint64, isTLS bool, origins string) *WsServer { - wsServer := &WsServer{ - token: token, - indiServerAddr: indiServerAddr, - phd2ServerAddr: phd2ServerAddr, - wsPort: wsPort, - isTLS: isTLS, - e: echo.New(), - upgrader: websocket.Upgrader{}, - connList: []net.Conn{}, - } - - if logutil.IsDev { - allowedOrigins["localhost"] = true - } - - // add optional additional origins - for _, orig := range strings.Split(origins, ",") { - allowedOrigins[strings.TrimSpace(orig)] = true - } - - // allow WS connections only from number of domains - wsServer.upgrader.CheckOrigin = func(r *http.Request) bool { - origin := r.Header["Origin"] - if len(origin) == 0 { - return false - } - u, err := url.Parse(origin[0]) - if err != nil { - return false - } - host, _, err := net.SplitHostPort(u.Host) - if err != nil { - return false - } - return allowedOrigins[host] - } - - return wsServer -} - -func (s *WsServer) newIndiConnection(c echo.Context) error { - // upgrade to WS connection - ws, err := s.upgrader.Upgrade(c.Response(), c.Request(), nil) - if err != nil { - return err - } - defer ws.Close() - - // open connection to INDI-Server - conn, err := net.Dial("tcp", s.indiServerAddr) - if err != nil { - return err - } - - // add to connection list - s.connList = append(s.connList, conn) - - // read messages from INDI-server and write them to WS - go func(indiConn net.Conn, wsConn *websocket.Conn) { - buf := make([]byte, lib.INDIServerMaxSendMsgSize, lib.INDIServerMaxSendMsgSize) - xmlFlattener := lib.NewXmlFlattener() - for { - // read from INDI-server - n, err := indiConn.Read(buf) - if err != nil { - indiConn.Close() - return - } - - jsonMessages := xmlFlattener.ConvertChunkToJSON(buf[:n]) - - // Write to WS - for _, m := range jsonMessages { - err = wsConn.WriteMessage(websocket.TextMessage, m) - if err != nil { - indiConn.Close() - return - } - } - } - }(conn, ws) - - // read messages from WS and write them to INDI-server - xmlFlattener := lib.NewXmlFlattener() - for { - // Read from WS - _, msg, err := ws.ReadMessage() - if err != nil { - conn.Close() - return err - } - - xmlMsg, err := xmlFlattener.ConvertJSONToXML(msg) - if err != nil { - log.Printf("could not convert json '%s' to xml: %s", string(msg), err) - continue - } - - // write to INDI server - _, err = conn.Write(xmlMsg) - if err != nil { - conn.Close() - return err - } - } -} - -func (s *WsServer) newPHD2Connection(c echo.Context) error { - return nil -} - -func (s *WsServer) Start() { - s.e.HideBanner = true - s.e.HidePort = true - - if !logutil.IsDev { - s.e.Logger.SetLevel(elog.OFF) - } - - s.e.Use(middleware.Recover()) - - // set CORS in case browser decides to do pre-flight OPTIONS request - // default ones - allowOrigins := []string{} - for orig := range allowedOrigins { - allowOrigins = append(allowOrigins, "http://"+orig) - allowOrigins = append(allowOrigins, "https://"+orig) - } - // optional ones - for _, orig := range strings.Split(s.origins, ",") { - allowOrigins = append(allowOrigins, "http://"+strings.TrimSpace(orig)) - allowOrigins = append(allowOrigins, "https://"+strings.TrimSpace(orig)) - } - // localhost for dev-mode - if logutil.IsDev { - allowOrigins = append(allowOrigins, "http://localhost:5000") - } - s.e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: allowOrigins, - AllowMethods: []string{http.MethodGet}, - })) - - // set auth middleware - s.e.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{ - KeyLookup: "query:token", - Validator: func(token string, eCtx echo.Context) (b bool, err error) { - return token == s.token, nil - }, - })) - - s.e.GET("/indiserver", s.newIndiConnection) - s.e.GET("/phd2server", s.newPHD2Connection) - - // check if we are running TLS - if s.isTLS { - // generate self-signed cert to serve WS over TLS - keyFile, certFile, err := getSelfSignedCert() - if err != nil { - log.Println("could not start WSS server, cert generation failed:", err) - return - } - err = s.e.StartTLS(fmt.Sprintf(":%d", s.wsPort), certFile, keyFile) - if err != nil { - log.Println("WSS server error:", err) - } - return - } - - // run WS - err := s.e.Start(fmt.Sprintf(":%d", s.wsPort)) - if err != nil { - log.Println("WSS server error:", err) - } -} - -func (s *WsServer) Stop() { - for _, conn := range s.connList { - conn.Close() - } - s.e.Shutdown(context.Background()) -}