Client/Server Basics
Wherein you build a fortune teller service and a client to talk to it.
Prerequisites:
All tutorials require Vanadium installation, and must run in a bash shell.
Further, please download this script then source it:
source ~/Downloads/scenario-a-setup.sh
This command wipes tutorial files and defines shell environment variables. If you start a new terminal, and just want to re-establish the environment, see instructions here.
If you would like to generate files from this tutorial without the copy/paste steps, download and source this script. Files will be created in the $V_TUT
directory.
Introduction
In the hello world tutorial, you got a client and server running.
In this tutorial, you'll build a slightly more complex client and server that will serve as the framework for deeper explorations of Vanadium security and service discovery.
This time, the server code is split into multiple files. The new code structure lets later tutorials change only the parts that need to change (e.g., a dispatcher, an authorizer), keeping later code shorter and clearer. That means doing a few odd things here, like writing whole code files that, for the time being, only return nil.
This tutorial will otherwise repeat the structure of the hello world tutorial.
Terminology
A Vanadium program can hold any number of servers and clients. When the distinction between these roles isn't important to the context, a program may be called a peer.
A server consists of an endpoint, a Vanadium address encapsulating a hostname, port and other information, and a dispatcher. A dispatcher maps an incoming request to an authorizer and a service. The request only makes it to the service if the authorizer approves. A dispatcher holds an authorizer and one or more services.
A client is just stub code, typed to match a particular service interface, bound to one or more endpoints on the network via a name (more on that in the naming tutorials). It's the boilerplate used to contact remote services.
A service has no knowledge of the authorizer protecting it. Neither the services nor the authorizers are aware of the dispatcher holding them.
A typical program will have one server and many clients. The program will offer some services to others, and contact other programs in an effort to provide these services (e.g. a television might contact several content providers).
A network of programs looks like this:
In this tutorial you'll build two programs. The first holds one server and no clients, and - at the risk of overloading terms - is simply called the server. The second, via similar reasoning, is called the client.
Define a service
A service is an object that responds to remote procedure calls (RPCs). The service you'll build here stores and offers up fortunes.
The interface
The first step to making a Vanadium service is to define it using the Vanadium Definition Language (VDL).
The following command (read more about cat
usage
here) will put the VDL in the right spot:
mkdir -p $V_TUT/src/fortune/ifc
cat - <<EOF >$V_TUT/src/fortune/ifc/fortune.vdl
package ifc
type Fortune interface {
// Returns a random fortune.
Get() (wisdom string | error)
// Adds a fortune to the set used by Get().
Add(wisdom string) error
}
EOF
This file defines the Go package ifc
(an abbreviation of
interface). You'll soon define the packages service
, util
and
main
. Package symbols will be isolated from each other, per Go
namespace rules, making it easier to reason about code dependencies.
Stub code
Use the fortune.vdl
you just made to create the file
fortune.vdl.go
. This Go code provides stubbed attachment points
that will soon be linked into a client and server.
VDLROOT=$VANADIUM_RELEASE/src/v.io/v23/vdlroot \
VDLPATH=$V_TUT/src \
$V_BIN/vdl generate --lang go $V_TUT/src/fortune/ifc
go build fortune/ifc
The go build
command here is just a check for errors. In this
case, the command doesn't create object or executable
files. In these tutorials, cat
commands that create code files
are immediately followed by commands that attempt compilation as a
quick check for errors. When the code file created is a main program,
go install
is used to install (or replace) an executable in
$V_TUT/bin
.
Implementation
The following implementation stores fortune strings in memory,
choosing one randomly when Get
is called. A client can add more
fortunes via Add
.
mkdir -p $V_TUT/src/fortune/service
cat - <<EOF >$V_TUT/src/fortune/service/service.go
package service
import (
"math/rand"
"fortune/ifc"
"sync"
"v.io/v23/context"
"v.io/v23/rpc"
)
type impl struct {
wisdom []string // All known fortunes.
random *rand.Rand // To pick a random index in 'wisdom'.
mu sync.RWMutex // To safely enable concurrent use.
}
// Makes an implementation.
func Make() ifc.FortuneServerMethods {
return &impl {
wisdom: []string{
"You will reach the heights of success.",
"Conquer your fears or they will conquer you.",
"Today is your lucky day!",
},
random: rand.New(rand.NewSource(99)),
}
}
func (f *impl) Get(_ *context.T, _ rpc.ServerCall) (blah string, err error) {
f.mu.RLock()
defer f.mu.RUnlock()
if len(f.wisdom) == 0 {
return "[empty]", nil
}
return f.wisdom[f.random.Intn(len(f.wisdom))], nil
}
func (f *impl) Add(_ *context.T, _ rpc.ServerCall, blah string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.wisdom = append(f.wisdom, blah)
return nil
}
EOF
go build fortune/service
Build a server
Service in hand, we need a place to put it.
Recall that a server associates an endpoint with a dispatcher, and a dispatcher contains one or more services. This tutorial covers the simple case of a program with just one server - just one active port. Further, the server has just one service and one authorizer.
Authorizer
Vanadium checks every request via a policy defined in an
implementation of security.Authorizer
.
The tutorials will use several authorizer implementations.
At any given time, one implementation will be provided by a factory
function called util.MakeAuthorizer
in a file called
authorizer.go
. To keep code simple (no branches, no signature
changes, etc.), switching to a new implementation means replacing the
file and recompiling.
The first version of authorizer.go
invokes the default Vanadium
authorization policy (to be discussed later):
mkdir -p $V_TUT/src/fortune/server/util
cat - <<EOF >$V_TUT/src/fortune/server/util/authorizer.go
package util
import (
"v.io/v23/security"
)
// Returns Vanadium's default authorizer.
func MakeAuthorizer() security.Authorizer {
return security.DefaultAuthorizer()
}
EOF
go build fortune/server/util
Dispatcher
Dispatcher implementations will be provided by a factory function
called util.MakeDispatcher
in a file called dispatcher.go
, an
arrangement allowing for low tech implementation swaps as described
above for the authorizer.
The version of dispatcher.go
defined here results in the use of a
dispatcher built into Vanadium. It's initialized with only one
service and uses reflection to invoke server methods.
mkdir -p $V_TUT/src/fortune/server/util
cat - <<EOF >$V_TUT/src/fortune/server/util/dispatcher.go
package util
import (
"v.io/v23/rpc"
)
// Returns nil to trigger use of the default dispatcher.
func MakeDispatcher() (d rpc.Dispatcher) {
return nil
}
EOF
go build fortune/server/util
Initializer
The following utility package allows endpoints to be written to a flag controlled file.
In this tutorial, rather than specify a port as was done in the hello world tutorial, we let the system pick a free one, and from it generate an endpoint specification.
Every time the server starts, the endpoint address changes. The address is written to a file named via a flag. A client must know this address in order to connect to the server, so it reads it from the file.
The mount table tutorial describes how a mount table replaces this file-based exchange with a service.
mkdir -p $V_TUT/src/fortune/server/util
cat - <<EOF >$V_TUT/src/fortune/server/util/initializer.go
package util
import (
"flag"
"fmt"
"io/ioutil"
"log"
"v.io/v23/naming"
)
var (
fileName = flag.String(
"endpoint-file-name", "",
"Write endpoint address to given file.")
)
func SaveEndpointToFile(e naming.Endpoint) {
if *fileName == "" {
return
}
contents := []byte(
naming.JoinAddressName(e.String(), "") + "\n")
if ioutil.WriteFile(*fileName, contents, 0644) != nil {
log.Panic("Error writing ", *fileName)
}
fmt.Printf("Wrote endpoint name to %v.\n", *fileName)
}
EOF
go build fortune/server/util
Installation
Construct a server executable by defining a main
function.
The server's custom behavior will be encapsulated by the authorizer and dispatcher defined above.
mkdir -p $V_TUT/src/fortune/server
cat - <<EOF >$V_TUT/src/fortune/server/main.go
package main
import (
"flag"
"fmt"
"log"
"fortune/ifc"
"fortune/server/util"
"fortune/service"
"v.io/v23"
"v.io/v23/rpc"
"v.io/x/ref/lib/signals"
_ "v.io/x/ref/runtime/factories/generic"
)
var (
serviceName = flag.String(
"service-name", "",
"Name for service in default mount table.")
)
func main() {
ctx, shutdown := v23.Init()
defer shutdown()
// Attach the 'fortune service' implementation
// defined above to a queriable, textual description
// of the implementation used for service discovery.
fortune := ifc.FortuneServer(service.Make())
// If the dispatcher isn't nil, it's presumed to have
// obtained its authorizer from util.MakeAuthorizer().
dispatcher := util.MakeDispatcher()
// Start serving.
var err error
var server rpc.Server
if dispatcher == nil {
// Use the default dispatcher.
_, server, err = v23.WithNewServer(
ctx, *serviceName, fortune, util.MakeAuthorizer())
} else {
_, server, err = v23.WithNewDispatchingServer(
ctx, *serviceName, dispatcher)
}
if err != nil {
log.Panic("Error serving service: ", err)
}
endpoint := server.Status().Endpoints[0]
util.SaveEndpointToFile(endpoint)
fmt.Printf("Listening at: %v\n", endpoint)
// Wait forever.
<-signals.ShutdownOnSignals(ctx)
}
EOF
go install fortune/server
Successful installation places a new executable in the bin directory.
ls $V_TUT/bin
The server is now ready to go. Next, make a client to work with it.
Build a client
This client is short-lived; it starts, makes one call (Get
or Add
)
depending on a flag value, then exits.
mkdir -p $V_TUT/src/fortune/client
cat - <<EOF >$V_TUT/src/fortune/client/main.go
package main
import (
"flag"
"fmt"
"time"
"fortune/ifc"
"v.io/v23"
"v.io/v23/context"
"v.io/x/lib/vlog"
_ "v.io/x/ref/runtime/factories/generic"
)
var (
server = flag.String(
"server", "", "Name of the server to connect to")
newFortune = flag.String(
"add", "", "A new fortune to add to the server's set")
)
func main() {
ctx, shutdown := v23.Init()
defer shutdown()
if *server == "" {
vlog.Error("--server must be specified")
return
}
f := ifc.FortuneClient(*server)
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
if *newFortune == "" { // --add flag not specified
fortune, err := f.Get(ctx)
if err != nil {
vlog.Errorf("error getting fortune: %v", err)
return
}
fmt.Println(fortune)
} else {
if err := f.Add(ctx, *newFortune); err != nil {
vlog.Errorf("error adding fortune: %v", err)
return
}
}
}
EOF
go install fortune/client
If that succeeds, a second binary will appear at
ls $V_TUT/bin
Run the binaries
First run
This first run will demonstrate that we don't yet have enough things defined to make a successful request.
Start the server in the background:
kill_tut_process TUT_PID_SERVER
$V_TUT/bin/server --endpoint-file-name $V_TUT/server.txt &
TUT_PID_SERVER=$!
Before entering its event loop, the server writes the endpoint address
to $V_TUT/server.txt
. The client will read it shortly.
Warning: The next command demonstrates failure!
The correct pieces are in place to attempt a request - but the next command shows that you've not yet established the conditions for authorization.
Now run the client, feeding it the server's endpoint, and attempt to get a fortune:
$V_TUT/bin/client --server `cat $V_TUT/server.txt`
The client will report an authorization error.
Let's change things to allow the call to succeed, then compare the two situations.
Second run
The simplest thing to do to allow a call to succeed under the default authorization policy is to run the client and server as the same principal (think of this as an identity for now). This makes the two processes indistinguishable from an authorization point of view.
To do this, create a principal called tutorial
. The next command
writes credentials associated with the name tutorial
into the
$V_TUT/cred/basics
directory:
$V_BIN/principal create \
--with-passphrase=false \
--overwrite $V_TUT/cred/basics tutorial
The --overwrite
flag is optional. Omit it if you want a warning if
you attempt to overwrite an existing principal.
Now kill the server and restart it, telling it to run with those credentials:
kill_tut_process TUT_PID_SERVER
$V_TUT/bin/server \
--v23.credentials $V_TUT/cred/basics \
--endpoint-file-name $V_TUT/server.txt &
TUT_PID_SERVER=$!
Run the client with the same credentials:
$V_TUT/bin/client \
--v23.credentials $V_TUT/cred/basics \
--server `cat $V_TUT/server.txt`
This time, the Get
RPC will succeed and a fortune will be displayed.
Likewise, Add
works:
$V_TUT/bin/client \
--v23.credentials $V_TUT/cred/basics \
--server `cat $V_TUT/server.txt` \
--add 'Fortune favors the bold.'
Feel free to rerun this without the --add
flag until you
randomly recover the new fortune.
Authorization
Vanadium is secure by default. All communication channels are encrypted and authenticated and all communication must satisfy an authorization policy.
In the first run above, no credentials were specified, so the server's Vanadium runtime assigned randomly generated credentials, including a characteristic name generated from the environment, e.g.
{user}@{hostname}_{randomNumber}
Likewise, the client ran with randomly generated credentials. Random credentials accompany requests, appear in logs, etc. and are preferrable to building a policy around missing credentials.
In the first run, the server didn't recognize the client's randomly different credentials and thus didn't authorize it.
In the second run, the server effectively recognized the client as itself, and authorized the call. The client and server ran with the same credentials - they had the same principal and blessings.
In practice, it is absolutely not normal to use the same principal for distinct processes. Usually the client and server processes would be on different machines, so running them as the same principal would imply copying private keys around the network, completely defeating the concept of private.
The vrpc client
Before continuing with more advanced tutorials, let's first introduce a generally useful generic client called vrpc.
It can talk to any Vanadium service. Try it with yours:
$V_BIN/vrpc --v23.credentials $V_TUT/cred/basics \
call `cat $V_TUT/server.txt` Get
$V_BIN/vrpc --v23.credentials $V_TUT/cred/basics \
call `cat $V_TUT/server.txt` Add \"More cowbell.\"
It is also possible to inspect the services offered by your server:
$V_BIN/vrpc --v23.credentials $V_TUT/cred/basics \
signature `cat $V_TUT/server.txt`
That output includes methods you wrote, plus built-in methods that support Vanadium security and service discovery.
Report single methods like this:
$V_BIN/vrpc --v23.credentials $V_TUT/cred/basics \
signature `cat $V_TUT/server.txt` Add
$V_BIN/vrpc --v23.credentials $V_TUT/cred/basics \
signature `cat $V_TUT/server.txt` Get
Clean up
kill_tut_process TUT_PID_SERVER
Summary
You created a server that hosted a Fortune service defined using VDL and made RPCs to it with a simple command line client.
The default security policy prevented your initial RPCs from working.
You 'fixed' this by running the client and server as the same principal, allowing requests to succeed.
You were introduced to the generic client
vrpc
.