blob: ea61737050f1c3b5cf387afcc076a33c624e248c [file] [log] [blame]
Jiri Simsa5293dcb2014-05-10 09:56:38 -07001// HTTP server that uses OAuth to create security.PrivateID objects.
2package main
3
4import (
5 "flag"
6 "fmt"
Asim Shankar71061572014-07-22 16:59:18 -07007 "html/template"
Robin Thellend37036802014-06-03 17:40:23 -07008 "net"
Jiri Simsa5293dcb2014-05-10 09:56:38 -07009 "net/http"
10 "os"
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -070011 "path/filepath"
Jiri Simsa5293dcb2014-05-10 09:56:38 -070012 "strings"
Asim Shankar71061572014-07-22 16:59:18 -070013 "time"
Jiri Simsa5293dcb2014-05-10 09:56:38 -070014
Jiri Simsa519c5072014-09-17 21:37:57 -070015 "veyron.io/veyron/veyron/lib/signals"
16 vsecurity "veyron.io/veyron/veyron/security"
17 "veyron.io/veyron/veyron/security/audit"
18 "veyron.io/veyron/veyron/services/identity/auditor"
19 "veyron.io/veyron/veyron/services/identity/blesser"
20 "veyron.io/veyron/veyron/services/identity/googleoauth"
21 "veyron.io/veyron/veyron/services/identity/handlers"
22 "veyron.io/veyron/veyron/services/identity/revocation"
23 services "veyron.io/veyron/veyron/services/security"
24 "veyron.io/veyron/veyron/services/security/discharger"
Tilak Sharma3ed30242014-08-11 11:45:55 -070025
Jiri Simsa519c5072014-09-17 21:37:57 -070026 "veyron.io/veyron/veyron2"
27 "veyron.io/veyron/veyron2/ipc"
28 "veyron.io/veyron/veyron2/naming"
29 "veyron.io/veyron/veyron2/rt"
30 "veyron.io/veyron/veyron2/security"
31 "veyron.io/veyron/veyron2/vlog"
Jiri Simsa5293dcb2014-05-10 09:56:38 -070032)
33
34var (
Asim Shankar61071792014-07-22 13:03:18 -070035 httpaddr = flag.String("httpaddr", "localhost:8125", "Address on which the HTTP server listens on.")
36 tlsconfig = flag.String("tlsconfig", "", "Comma-separated list of TLS certificate and private key files. If empty, will not use HTTPS.")
37 // TODO(ashankar): Revisit the choices for -vaddr and -vprotocol once the proxy design in relation to mounttables has been finalized.
38 address = flag.String("vaddr", "proxy.envyor.com:8100", "Address on which the Veyron blessing server listens on. Enabled iff --google_config is set")
39 protocol = flag.String("vprotocol", "veyron", "Protocol used to interpret --vaddr")
Jiri Simsa5293dcb2014-05-10 09:56:38 -070040 host = flag.String("host", defaultHost(), "Hostname the HTTP server listens on. This can be the name of the host running the webserver, but if running behind a NAT or load balancer, this should be the host name that clients will connect to. For example, if set to 'x.com', Veyron identities will have the IssuerName set to 'x.com' and clients can expect to find the public key of the signer at 'x.com/pubkey/'.")
Jiri Simsa5293dcb2014-05-10 09:56:38 -070041 minExpiryDays = flag.Int("min_expiry_days", 365, "Minimum expiry time (in days) of identities issued by this server")
Asim Shankar71061572014-07-22 16:59:18 -070042
Asim Shankar3afe7902014-08-12 11:43:48 -070043 auditprefix = flag.String("audit", "", "File prefix to files where auditing information will be written.")
44 auditfilter = flag.String("audit_filter", "", "If non-empty, instead of starting the server the audit log will be dumped to STDOUT (with the filter set to the value of this flag. '/' can be used to dump all events).")
45
Asim Shankar7a721752014-08-02 14:27:23 -070046 // Configuration for various Google OAuth-based clients.
Asim Shankar3afe7902014-08-12 11:43:48 -070047 googleConfigWeb = flag.String("google_config_web", "", "Path to JSON-encoded OAuth client configuration for the web application that renders the audit log for blessings provided by this provider.")
Asim Shankar7a721752014-08-02 14:27:23 -070048 googleConfigInstalled = flag.String("google_config_installed", "", "Path to the JSON-encoded OAuth client configuration for installed client applications that obtain blessings (via the OAuthBlesser.BlessUsingAuthorizationCode RPC) from this server (like the 'identity' command like tool and its 'seekblessing' command.")
49 googleConfigChrome = flag.String("google_config_chrome", "", "Path to the JSON-encoded OAuth client configuration for Chrome browser applications that obtain blessings from this server (via the OAuthBlesser.BlessUsingAccessToken RPC) from this server.")
Srdjan Petrovica1e6ddb2014-09-08 10:18:44 -070050 googleConfigAndroid = flag.String("google_config_android", "", "Path to the JSON-encoded OAuth client configuration for Android applications that obtain blessings from this server (via the OAuthBlesser.BlessUsingAccessToken RPC) from this server.")
Asim Shankar1c3b1812014-07-31 18:54:51 -070051 googleDomain = flag.String("google_domain", "", "An optional domain name. When set, only email addresses from this domain are allowed to authenticate via Google OAuth")
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -070052
53 // Revoker/Discharger configuration
54 revocationDir = flag.String("revocation_dir", filepath.Join(os.TempDir(), "revocation_dir"), "Path where the revocation manager will store caveat and revocation information.")
Jiri Simsa5293dcb2014-05-10 09:56:38 -070055)
56
57func main() {
Jiri Simsa5293dcb2014-05-10 09:56:38 -070058 flag.Usage = usage
Asim Shankar3afe7902014-08-12 11:43:48 -070059 r := rt.Init(providerIdentity())
Bogdan Caprita4258d882014-07-02 09:15:22 -070060 defer r.Cleanup()
Jiri Simsa5293dcb2014-05-10 09:56:38 -070061
Asim Shankar3afe7902014-08-12 11:43:48 -070062 if len(*auditfilter) > 0 {
63 dumpAuditLog()
64 return
65 }
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -070066
67 // Calling with empty string returns a empty RevocationManager
68 revocationManager, err := revocation.NewRevocationManager(*revocationDir)
69 if err != nil {
70 vlog.Fatalf("Failed to start RevocationManager: %v", err)
71 }
72
Jiri Simsa5293dcb2014-05-10 09:56:38 -070073 // Setup handlers
Jiri Simsa5293dcb2014-05-10 09:56:38 -070074 http.Handle("/pubkey/", handlers.Object{r.Identity().PublicID().PublicKey()}) // public key of this identity server
Robin Thellendb6406092014-05-12 16:38:58 -070075 if enableRandomHandler() {
76 http.Handle("/random/", handlers.Random{r}) // mint identities with a random name
77 }
78 http.HandleFunc("/bless/", handlers.Bless) // use a provided PrivateID to bless a provided PublicID
Asim Shankar61071792014-07-22 13:03:18 -070079
Jiri Simsa5293dcb2014-05-10 09:56:38 -070080 // Google OAuth
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -070081 ipcServer, ipcServerEP, err := setupGoogleBlessingDischargingServer(r, revocationManager)
Asim Shankar7a721752014-08-02 14:27:23 -070082 if err != nil {
83 vlog.Fatalf("Failed to setup veyron services for blessing: %v", err)
84 }
85 if ipcServer != nil {
Asim Shankar71061572014-07-22 16:59:18 -070086 defer ipcServer.Stop()
87 }
Srdjan Petrovica1e6ddb2014-09-08 10:18:44 -070088 if clientID, clientSecret, ok := getOAuthClientIDAndSecret(*googleConfigWeb); ok && len(*auditprefix) > 0 {
Jiri Simsa5293dcb2014-05-10 09:56:38 -070089 n := "/google/"
90 http.Handle(n, googleoauth.NewHandler(googleoauth.HandlerArgs{
Suharsh Sivakumar5ee0ee62014-09-04 13:16:34 -070091 Addr: fmt.Sprintf("%s%s", httpaddress(), n),
92 ClientID: clientID,
93 ClientSecret: clientSecret,
94 Auditor: *auditprefix,
95 RevocationManager: revocationManager,
Jiri Simsa5293dcb2014-05-10 09:56:38 -070096 }))
97 }
Asim Shankar71061572014-07-22 16:59:18 -070098 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
99 var servers []string
100 if ipcServer != nil {
101 servers, _ = ipcServer.Published()
102 }
Asim Shankar3afe7902014-08-12 11:43:48 -0700103 if len(servers) == 0 {
104 // No addresses published, publish the endpoint instead (which may not be usable everywhere, but oh-well).
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -0700105 servers = append(servers, ipcServerEP.String())
Asim Shankar3afe7902014-08-12 11:43:48 -0700106 }
Asim Shankar71061572014-07-22 16:59:18 -0700107 args := struct {
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -0700108 Self string
109 GoogleWeb, RandomWeb bool
110 GoogleServers, DischargeServers []string
Asim Shankar71061572014-07-22 16:59:18 -0700111 }{
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -0700112 Self: rt.R().Identity().PublicID().Names()[0],
113 GoogleWeb: len(*googleConfigWeb) > 0,
114 RandomWeb: enableRandomHandler(),
115 GoogleServers: appendSuffixTo(servers, "google"),
116 DischargeServers: appendSuffixTo(servers, "discharger"),
Asim Shankar71061572014-07-22 16:59:18 -0700117 }
118 if err := tmpl.Execute(w, args); err != nil {
119 vlog.Info("Failed to render template:", err)
120 }
121 })
Asim Shankar1c3b1812014-07-31 18:54:51 -0700122 vlog.Infof("Running HTTP server at: %v", httpaddress())
Asim Shankar61071792014-07-22 13:03:18 -0700123 go runHTTPServer(*httpaddr)
124 <-signals.ShutdownOnSignals()
Jiri Simsa5293dcb2014-05-10 09:56:38 -0700125}
126
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -0700127func appendSuffixTo(objectname []string, suffix string) []string {
128 names := make([]string, len(objectname))
129 for i, o := range objectname {
130 names[i] = naming.JoinAddressName(o, suffix)
131 }
132 return names
133}
134
135// newDispatcher returns a dispatcher for both the blessing and the discharging service.
136// their suffix. ReflectInvoker is used to invoke methods.
137func newDispatcher(params blesser.GoogleParams) ipc.Dispatcher {
138 blessingService := ipc.ReflectInvoker(blesser.NewGoogleOAuthBlesserServer(params))
139 dischargerService := ipc.ReflectInvoker(services.NewServerDischarger(discharger.NewDischarger(params.R.Identity())))
Asim Shankar9f6db082014-08-27 16:44:03 -0700140 allowEveryoneACLAuth := vsecurity.NewACLAuthorizer(security.ACL{In: map[security.BlessingPattern]security.LabelSet{
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -0700141 security.AllPrincipals: security.AllLabels,
Asim Shankar9f6db082014-08-27 16:44:03 -0700142 }})
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -0700143 return &dispatcher{blessingService, dischargerService, allowEveryoneACLAuth}
144}
145
146type dispatcher struct {
147 blessingInvoker, dischargerInvoker ipc.Invoker
148 auth security.Authorizer
149}
150
151func (d dispatcher) Lookup(suffix, method string) (ipc.Invoker, security.Authorizer, error) {
152 switch suffix {
153 case "google":
154 return d.blessingInvoker, d.auth, nil
155 case "discharger":
156 return d.dischargerInvoker, d.auth, nil
157 default:
158 return nil, nil, fmt.Errorf("suffix does not exist")
159 }
160}
161
162// Starts the blessing service and the discharging service on the same port.
163func setupGoogleBlessingDischargingServer(r veyron2.Runtime, revocationManager *revocation.RevocationManager) (ipc.Server, naming.Endpoint, error) {
Asim Shankar7a721752014-08-02 14:27:23 -0700164 var enable bool
165 params := blesser.GoogleParams{
166 R: r,
167 BlessingDuration: time.Duration(*minExpiryDays) * 24 * time.Hour,
168 DomainRestriction: *googleDomain,
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -0700169 RevocationManager: revocationManager,
Asim Shankar7a721752014-08-02 14:27:23 -0700170 }
Srdjan Petrovica1e6ddb2014-09-08 10:18:44 -0700171 if clientID, clientSecret, ok := getOAuthClientIDAndSecret(*googleConfigInstalled); ok {
Asim Shankar7a721752014-08-02 14:27:23 -0700172 enable = true
173 params.AuthorizationCodeClient.ID = clientID
174 params.AuthorizationCodeClient.Secret = clientSecret
175 }
Srdjan Petrovica1e6ddb2014-09-08 10:18:44 -0700176 if clientID, ok := getOAuthClientID(*googleConfigChrome); ok {
Asim Shankar7a721752014-08-02 14:27:23 -0700177 enable = true
Srdjan Petrovica1e6ddb2014-09-08 10:18:44 -0700178 params.AccessTokenClients = append(params.AccessTokenClients, struct{ ID string }{clientID})
179 }
180 if clientID, ok := getOAuthClientID(*googleConfigAndroid); ok {
181 enable = true
182 params.AccessTokenClients = append(params.AccessTokenClients, struct{ ID string }{clientID})
Asim Shankar7a721752014-08-02 14:27:23 -0700183 }
184 if !enable {
Asim Shankar3afe7902014-08-12 11:43:48 -0700185 return nil, nil, nil
Asim Shankar7a721752014-08-02 14:27:23 -0700186 }
Asim Shankar61071792014-07-22 13:03:18 -0700187 server, err := r.NewServer()
188 if err != nil {
Asim Shankar3afe7902014-08-12 11:43:48 -0700189 return nil, nil, fmt.Errorf("failed to create new ipc.Server: %v", err)
Asim Shankar61071792014-07-22 13:03:18 -0700190 }
191 ep, err := server.Listen(*protocol, *address)
192 if err != nil {
Asim Shankar3afe7902014-08-12 11:43:48 -0700193 return nil, nil, fmt.Errorf("server.Listen(%q, %q) failed: %v", "tcp", *address, err)
Asim Shankar61071792014-07-22 13:03:18 -0700194 }
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -0700195 params.DischargerLocation = naming.JoinAddressName(ep.String(), "discharger")
196 dispatcher := newDispatcher(params)
197 objectname := fmt.Sprintf("identity/%s", r.Identity().PublicID().Names()[0])
198 if err := server.Serve(objectname, dispatcher); err != nil {
199 return nil, nil, fmt.Errorf("failed to start Veyron services: %v", err)
Asim Shankar61071792014-07-22 13:03:18 -0700200 }
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -0700201 vlog.Infof("Google blessing and discharger services enabled at endpoint %v and name %q", ep, objectname)
Asim Shankar3afe7902014-08-12 11:43:48 -0700202 return server, ep, nil
Asim Shankar61071792014-07-22 13:03:18 -0700203}
Jiri Simsa5293dcb2014-05-10 09:56:38 -0700204
Asim Shankar3afe7902014-08-12 11:43:48 -0700205func enableTLS() bool { return len(*tlsconfig) > 0 }
206func enableRandomHandler() bool {
Srdjan Petrovica1e6ddb2014-09-08 10:18:44 -0700207 return len(*googleConfigInstalled)+len(*googleConfigWeb)+len(*googleConfigChrome)+len(*googleConfigAndroid) == 0
Asim Shankar3afe7902014-08-12 11:43:48 -0700208}
Srdjan Petrovica1e6ddb2014-09-08 10:18:44 -0700209func getOAuthClientID(config string) (clientID string, ok bool) {
Asim Shankar71061572014-07-22 16:59:18 -0700210 fname := config
Asim Shankar61071792014-07-22 13:03:18 -0700211 if len(fname) == 0 {
Srdjan Petrovica1e6ddb2014-09-08 10:18:44 -0700212 return "", false
213 }
214 f, err := os.Open(fname)
215 if err != nil {
216 vlog.Fatalf("Failed to open %q: %v", fname, err)
217 }
218 defer f.Close()
219 clientID, err = googleoauth.ClientIDFromJSON(f)
220 if err != nil {
221 vlog.Fatalf("Failed to decode JSON in %q: %v", fname, err)
222 }
223 return clientID, true
224}
225func getOAuthClientIDAndSecret(config string) (clientID, clientSecret string, ok bool) {
226 fname := config
227 if len(fname) == 0 {
228 return "", "", false
Asim Shankar61071792014-07-22 13:03:18 -0700229 }
230 f, err := os.Open(fname)
231 if err != nil {
232 vlog.Fatalf("Failed to open %q: %v", fname, err)
233 }
234 defer f.Close()
235 clientID, clientSecret, err = googleoauth.ClientIDAndSecretFromJSON(f)
236 if err != nil {
237 vlog.Fatalf("Failed to decode JSON in %q: %v", fname, err)
238 }
Srdjan Petrovica1e6ddb2014-09-08 10:18:44 -0700239 return clientID, clientSecret, true
Asim Shankar61071792014-07-22 13:03:18 -0700240}
Asim Shankar61071792014-07-22 13:03:18 -0700241func runHTTPServer(addr string) {
Jiri Simsa5293dcb2014-05-10 09:56:38 -0700242 if !enableTLS() {
Jiri Simsa5293dcb2014-05-10 09:56:38 -0700243 if err := http.ListenAndServe(addr, nil); err != nil {
244 vlog.Fatalf("http.ListenAndServe failed: %v", err)
245 }
246 return
247 }
248 paths := strings.Split(*tlsconfig, ",")
249 if len(paths) != 2 {
250 vlog.Fatalf("Could not parse --tlsconfig. Must have exactly two components, separated by a comma")
251 }
252 vlog.Infof("Starting HTTP server with TLS using certificate [%s] and private key [%s] at https://%s", paths[0], paths[1], addr)
253 if err := http.ListenAndServeTLS(addr, paths[0], paths[1], nil); err != nil {
254 vlog.Fatalf("http.ListenAndServeTLS failed: %v", err)
255 }
256}
257
258func usage() {
259 fmt.Fprintf(os.Stderr, `%s starts an HTTP server that mints veyron identities in response to GET requests.
260
261To generate TLS certificates so the HTTP server can use SSL:
262go run $GOROOT/src/pkg/crypto/tls/generate_cert.go --host <IP address>
263
Asim Shankar1c3b1812014-07-31 18:54:51 -0700264To generate an identity for this service itself, use:
265go install veyron/tools/identity && ./bin/identity generate <name> ><filename>
266and set the VEYRON_IDENTITY environment variable when running this application.
267
Jiri Simsa5293dcb2014-05-10 09:56:38 -0700268To enable use of Google APIs to use Google OAuth for authorization, set --google_config,
269which must point to the contents of a JSON file obtained after registering your application
270with the Google Developer Console at:
271https://code.google.com/apis/console
272More details on Google OAuth at:
273https://developers.google.com/accounts/docs/OAuth2Login
274
275Flags:
276`, os.Args[0])
277 flag.PrintDefaults()
278}
279
280func defaultHost() string {
281 host, err := os.Hostname()
282 if err != nil {
283 vlog.Fatalf("Failed to get hostname: %v", err)
284 }
285 return host
286}
287
Asim Shankar3afe7902014-08-12 11:43:48 -0700288// providerIdentity returns the identity of the identity provider (i.e., this program) itself.
289func providerIdentity() veyron2.ROpt {
290 // TODO(ashankar): This scheme of initializing a runtime just to share the "load identity" code is ridiculous.
291 // Figure out a way to update the runtime's identity with a wrapper and avoid this spurios "New" call.
292 r, err := rt.New()
293 if err != nil {
294 vlog.Fatal(err)
295 }
296 defer r.Cleanup()
297 id := r.Identity()
298 if len(*auditprefix) > 0 {
299 auditor, err := auditor.NewFileAuditor(*auditprefix)
300 if err != nil {
301 vlog.Fatal(err)
302 }
303 id = audit.NewPrivateID(id, auditor)
304 }
305 return veyron2.RuntimeID(id)
306}
307
Asim Shankar1c3b1812014-07-31 18:54:51 -0700308func httpaddress() string {
309 _, port, err := net.SplitHostPort(*httpaddr)
Jiri Simsa5293dcb2014-05-10 09:56:38 -0700310 if err != nil {
Asim Shankar1c3b1812014-07-31 18:54:51 -0700311 vlog.Fatalf("Failed to parse %q: %v", *httpaddr, err)
Jiri Simsa5293dcb2014-05-10 09:56:38 -0700312 }
Asim Shankar1c3b1812014-07-31 18:54:51 -0700313 scheme := "http"
314 if enableTLS() {
315 scheme = "https"
Jiri Simsa5293dcb2014-05-10 09:56:38 -0700316 }
Asim Shankar1c3b1812014-07-31 18:54:51 -0700317 return fmt.Sprintf("%s://%s:%v", scheme, *host, port)
Jiri Simsa5293dcb2014-05-10 09:56:38 -0700318}
Asim Shankar71061572014-07-22 16:59:18 -0700319
Asim Shankar3afe7902014-08-12 11:43:48 -0700320func dumpAuditLog() {
321 if len(*auditprefix) == 0 {
322 vlog.Fatalf("Must set --audit")
323 }
324 ch, err := auditor.ReadAuditLog(*auditprefix, *auditfilter)
325 if err != nil {
326 vlog.Fatal(err)
327 }
328 idx := 0
329 for entry := range ch {
330 fmt.Printf("%6d) %v\n", idx, entry)
331 idx++
332 }
333}
334
Asim Shankar71061572014-07-22 16:59:18 -0700335var tmpl = template.Must(template.New("main").Parse(`<!doctype html>
336<html>
337<head>
338<meta charset="UTF-8">
339<title>Veyron Identity Server</title>
340<meta name="viewport" content="width=device-width, initial-scale=1.0">
341<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
342</head>
343<body>
344<div class="container">
Asim Shankar3afe7902014-08-12 11:43:48 -0700345<div class="page-header"><h2>{{.Self}}</h2><h4>A Veyron Identity Provider</h4></div>
Asim Shankar71061572014-07-22 16:59:18 -0700346<div class="well">
Asim Shankar3afe7902014-08-12 11:43:48 -0700347This is a Veyron identity provider that provides blessings with the name prefix <mark>{{.Self}}</mark>. The public
348key of this provider is available in <a class="btn btn-xs btn-primary" href="/pubkey/base64vom">base64-encoded-vom-encoded</a> format.
Asim Shankar71061572014-07-22 16:59:18 -0700349</div>
Asim Shankar3afe7902014-08-12 11:43:48 -0700350
Asim Shankar71061572014-07-22 16:59:18 -0700351{{if .GoogleServers}}
352<div class="well">
Asim Shankar3afe7902014-08-12 11:43:48 -0700353Blessings are provided via Veyron RPCs to: <tt>{{range .GoogleServers}}{{.}}{{end}}</tt>
Asim Shankar71061572014-07-22 16:59:18 -0700354</div>
355{{end}}
Suharsh Sivakumarfb5cbb72014-08-27 13:14:22 -0700356{{if .DischargeServers}}
357<div class="well">
358RevocationCaveat Discharges are provided via Veyron RPCs to: <tt>{{range .DischargeServers}}{{.}}{{end}}</tt>
359</div>
360{{end}}
361
Asim Shankar71061572014-07-22 16:59:18 -0700362
363{{if .GoogleWeb}}
Asim Shankar3afe7902014-08-12 11:43:48 -0700364<div class="well">
365This page provides the ability to <a class="btn btn-xs btn-primary" href="/google/auth">enumerate</a> blessings provided with your
366email address as the name.
367</div>
Asim Shankar71061572014-07-22 16:59:18 -0700368{{end}}
Asim Shankar3afe7902014-08-12 11:43:48 -0700369
Asim Shankar71061572014-07-22 16:59:18 -0700370{{if .RandomWeb}}
Asim Shankar3afe7902014-08-12 11:43:48 -0700371<div class="well">
372You can obtain a randomly assigned PrivateID <a class="btn btn-sm btn-primary" href="/random/">here</a>
373</div>
Asim Shankar71061572014-07-22 16:59:18 -0700374{{end}}
Asim Shankar3afe7902014-08-12 11:43:48 -0700375
376<div class="well">
377You can use <a class="btn btn-xs btn-primary" href="/bless/">this form</a> to offload crypto for blessing to this HTTP server
378</div>
Asim Shankar71061572014-07-22 16:59:18 -0700379
380</div>
381</body>
382</html>`))