Often, our application needs to be able to accept commands from a local and/or remote application or troubleshooting utility. In many cases it is enough to set up a service that will accept connections and evaluate commands sent over them.
We can do this by using the comm
package that offers both client and server functionality. This package allows many use cases from a simple server that evaluates all commands to a system that allows only certain tasks to be performed. This package uses TCP connections so it is possible to handle both local and remote connections. However, it is configurable whether connections from other computers are allowed.
Using a comm
package we can also connect to our application from applications such as TclDevKit inspector from ActiveState, which can help in troubleshooting daemon-like applications. This is described in more detail in chapter 4.
Using the comm
protocol requires the use of the comm::comm
command by providing an actual command to perform as its first argument.
One of the subcommands is config
, which allows us to get and set the configuration. It accepts either no arguments, in which case it returns list of name-value pairs specifying all options and their values, or one or more arguments. Specifying only the name of an option returns its current value. Specifying one or more name-value pairs causes those to be set to specific values.
The most important options are -port
and -local
. The first one allows us to specify the TCP port to listen on, or to retrieve the port currently used by default the system assigns an available port. The second option allows us to specify whether only local connections should be accepted or if both local and remote connections are allowed by default only local connections are accepted.
In most cases it is best to assign a port either hardcoded or read from a configuration file. However, in some cases it makes sense to have it assigned by the operating system in this case we can simply load comm package and get port that was assigned by default. We can then read it using the self
subcommand, which returns the currently used port.
For example:
package require comm puts "Comm port: [comm::comm self]"
If we want to define which port to use, we can simply set the -port
option. For example:
package require comm comm::comm config -port 1991
For the purpose of this section we'll use port 1991
throughout all further examples.
Assuming that our application is in an event loop for example by invoking vwait forever
, we can now send commands to it. We can do this by invoking the send subcommand, specifying identifier of the application and command to run. In the case of processes on the same system identifier is simply the port number 1991
in our case. In case of remote systems, identifier is a list containing port and hostname for example {1991 192.168.2.12}
.
For example, in order to calculate 1+1 using remote interpreter we can do:
package require comm set result [comm::comm send 1991 expr {1+1}] puts "1 + 1 = $result"
Similarly calculating the same value using a remote computer can be done by changing our client's code to:
set result [comm::comm send {1991 192.168.1.12} expr {1+1}]
The remaining parts of the example are the same. In addition, the server needs to be configured to accept remote connections:
comm::comm config -port 1991 remote 1
We can also perform asynchronous operations that will not block the client until the server completes the operation. One case is when our application only needs to send a command without needing its result. In this case, we simply need to provide the -async
flag before the identifier of the remote system. For example:
comm::comm send -async 1991 {after 3000}
This will cause the remote system to wait for 3 seconds, but the send
command returns instantly.
Asynchronous commands can also be used to perform long running operations, or for actions that will require user input. In this case we need to specify the -command
option followed by the command to be run when the results have been received.
This command will be run with name-value pairs containing information about both the channel and the result appended to it. The item -result
contains the result from the command or the error description for errors. The item -code
will specify result of the command as 0 indicating successful evaluation of the command and 1 indicating an error. In case of an error -errorinfo
and -errorcode
are set respectively to indicate error information. More details on error information can be found in Chapter 4. These items can be retrieved using dict
command.
For example, in order to calculate the MD5 checksum of a large remote file we can run:
comm::comm send -command oncallback 1991 {md5::md5 -hex [string repeat "Tcl Book" 16777216]}
And in order to define the command that handles the result we can do:
proc oncallback {args} { if {[dict get $args -code] != 0} { puts "Error while evaluating command" } else { puts "Result: [dict get $args -result]" } }
This callback checks whether the command returned an error and if not, prints out the result from the command.
In many cases, we want our remote procedure to send a result that needs operations which might not happen immediately, such as passing a request to another machine or application.
A typical example can be sending a request to an other Tcl application over comm
or sending a request over HTTP. In such cases we can receive the result after long period of time and we might want to return information to our caller when our event handler returns.
In such cases we can use the return_async
subcommand from the comm::comm
command in the Tcl interpreter that is the server for a particular connection. This command returns a new command, usually called future that we can invoke later on in order to pass the result.
In order to return a value we can invoke the return
subcommand of the future object. It accepts a value to return, optionally preceded by the return code, which can be supplied by providing the -code
option before the actual result.
For example, we can return the result after several seconds by doing:
proc returnAfter {after code value} { set future [comm::comm return_async] after $after [list $future return -code $code $value] }
In this case, whatever we return from within the procedure will be ignored. Invoking the return
subcommand also cleans up the future object.
We can invoke the command from the client in the same way regardless if it uses asynchronous return or if it returns instantly. For example we can run:
set v [comm::comm send 1991 returnAfter 3000 0 1]
Asynchronous returns are usually used when we need to invoke asynchronous operations, such as sending an HTTP request. A good example might be how to implement a comm
proxy service:
proc proxy {id args} { set future [comm::comm return_async] comm::comm send -command [list proxyReturn $future] $id {*}$args }
This command takes the id of the next hop as an argument and the remaining arguments are treated as commands to pass on to the next hop. It also sets up asynchronous return command which is used in proxyReturn
procedure. It can look like this:
proc proxyReturn {future args} { $future return -code [dict get $args -code] [dict get $args -result] }
This simply passes the result back to the client calling the proxy
command. For example assuming our proxy comm
server is listening on port 1992, we can pass the query to actual server on port 1991 by doing:
set v [comm::comm send 1992 proxy 1991 returnAfter 3000 0 1]
We can also use multiple hops and over network assuming we need to send all requests first via comm on host 192.168.2.12
and further via host 10.0.0.1
, we can do:
set v [comm::comm send {1991 192.168.2.12} proxy {1991 10.0.0.1} proxy returnAfter 3000 0 1]
All of the proxy comm
servers will perform this operation without waiting so it is possible to proxy multiple requests at a time this way, regardless of how much it takes to complete the operation.
An obvious concern is that this allows everyone to send commands to be run either only from local machine or from the entire network. While for some operations this is perfectly acceptable, there are cases where such a model might not work. An example could be that if our system is shared among users in this case anyone knowing the port might send commands to our application. Another case is when our application accepts commands from remote machines, unless access to the network is strictly limited.
The comm
package allows the specification of hooks that are evaluated whenever an event occurs. These can be used for both comm clients and servers.
We can set up a script that will be evaluated when an event occurs by invoking the hook
subcommand of the comm::comm
command. It accepts event type and script as its arguments. These can be used to authenticate and optionally reject incoming connections, validating commands to evaluate as well as their responses.
Hook scripts can read various information using variables that are accessible while it is running. These variables are:
Variable |
Description |
---|---|
|
Comm command that is associated with the event, by default it is |
|
Id of the remote interpreter |
|
Tcl channel associated with connection |
|
IP address of the remote peer; used for authenticating remote connection |
|
Port used by the remote peer; used for authenticating remote connection |
|
Command type for evaluating a script |
|
Tcl command to evaluate |
|
Host to connect to; used by client hooks |
|
Port to connect to; used by client hooks |
Not all variables are accessible for all hooks and availability is described along with each of the events.
The event type incoming
is run for listening connections whenever a remote interpreter is connecting. It can be used to log incoming connections and/or reject connections based on remote address. This hook can reject a connection by throwing an error. This hook allows access to chan, fid, addr
and remport
variables.
For example, in order to accept connections only from hosts matching 127.*
or 192.168.*
pattern, we can add the following hook:
comm::comm hook incoming { puts "comm—Connection from $addr" if {(![string match "127.*" $addr]) && (![string match "192.168.*" $addr])} { error "Connection from unknown host $addr" } }
This hook prints information about incoming connections to standard output. Then if the addr
variable (provided by comm
package) does not match any of defined patterns, an error is thrown. This error causes comm to close the connection and pass this error to client.
Event type eval
is run whenever a command is to be evaluated. It provides information in chan, id, cmd
and buffer
variables. In this case it is possible to modify command to evaluate, return any result from within the hook or throw an error in case a specified command should not be run.
If the eval
hook returns with an error, this error is passed back to the original client. For example, in order to refuse all commands except for the known ones we can do:
comm::comm hook eval { if {[lsearch $::validcommands [lindex $buffer 0]] < 0} { error "Command not authorized" } }
In this case, we search for first element of the command in global validcommands
variable if it contains the command we allow execution of the command.
Usually it is a better idea to either pass the command to a procedure that will handle it, which can do additional checks or make sure that only a subset of functionality is exposed. For example we can rewrite the buffer
variable to pass actual command as argument by doing:
comm::comm hook eval { set buffer [list [list myhandler $buffer]] }
This way we pass the original buffer as first argument to myhandler
command. As the comm
package tries to concatenate all elements into a single list, we need to build a list of lists so that our myhandler
is invoked with just one argument. We can then write a simple function that writes the actual command and result by doing:
proc myhandler {command} { puts "Evaluating '$buffer'" set rc [eval $command] puts "Result: '$rc'" return $rc }
This command prints out the command to execute, evaluates it and prints out results.
Another option is to evaluate the command on our own instead of having it done by the comm
package. We can use the return command inside the hook to force the comm
package to pass our result back to the client. For example, we can invoke myhandler
in the same way by doing:
comm::comm hook eval { return [myhandler $buffer] }
It is also possible to use an asynchronous handler for commands that are evaluated by our hook. For example, we can do the following:
proc myhandler {command} { if {$command == "asynctest"} { set future [comm::comm return_async] after 5000 [list $future return "After 5 seconds"] return } set rc [eval $command] return $rc }
This command has a special case where if the command invoked is asynctest
, we return the result after 5 seconds. Otherwise we just evaluate whatever command user provided and return it.
In many cases it is good practice to limit the operations remote applications can perform provide only a set of commands that user can access and allow only access to a subset that user has access to. We can even introduce an idea of a session identifier which is given when authenticating a user and can expire after some time.
For example we can work in the following way first operation a user should perform is authorizing using username and password. Next the server provides a session identifier that can be used to invoke other commands. We can do this by sending a session identifier as the first argument and assume that if session is not set, remaining arguments are username and password. If the session is valid, we can assume that the second argument is a command name and the remaining arguments are passed to the command.
We can create a command that handles buffer in this way by doing:
proc so::handle {buffer} { set session [lindex $buffer 0] if {$session == ""} { lassign $buffer session user pass set sid [authorize $user $pass] return $sid } elseif {[check $session [lindex $buffer 1]]} { return [so::service::[lindex $buffer 1] {*}[lrange $buffer 2 end]] } else { error "Authorization failed" } }
What the procedure does is check first element of a list if it is empty, we assign the second and third argument as the username and password and invoke the authorize
command, which returns a new session identifier or an empty string. If the session is set, and if the user is authorized to run specified command, we run it assuming it is in the so::service
namespace to avoid running commands outside of the specified namespace. If the session is not valid or the user is not authorized to run a specified command, we throw an error.
We also need to set up a hook for eval
event:
comm::comm hook eval { return [so::handle [lrange $buffer 0 end]] }
We're using the lrange
command to make sure the user input is a valid list, just as a precaution.
Our sample authorization command should authorize the user, generate a session ID (for example using uuid
package) and return it. A simple one, with a hardcoded username and password could be as follows:
proc so::authorize {user password} { variable sessions if {($user == "admin") && ($password == "pass")} { set sid [uuid::uuid generate] set sessions($sid) $sid } return $sid }
This checks if the username
is admin and the password is pass
if so, it sets up a session and stores it in namespace variable sessions
. We can also implement a trivial checking by doing:
proc so::check {sid command} { variable sessions if {[info exists sessions($sid)]} { return true } return false }
And now we have a safe comm
based service that can be used to offer services. We can also add a simple command on top of this infrastructure, for example:
proc so::service::add {a b} { return [expr {$a + $b}] }
In order to use it we can now use the following code on the client:
set sid [comm::comm send 1991 {} admin pass] if {$sid == ""} { error "Unable to authorize" } set v [comm::comm send 1991 $sid add 1 2]
The main issue we need to implement is support for sessions to expire, for example if no operation is performed for some time, session is removed from sessions
variable.
18.221.136.142