Documentation

eduVPN for Linux

Building a client#

This chapter is a high-level overview on how to use eduvpn-common and build your own eduVPN/Let’s Connect! client. In this chapter, we go over the basics of how the interop between Go and language x works, say something about the architecture, explain where to find detailed API documentation, explain the state machine, give a typical flow for a client and give a follow along tutorial on building an eduVPN client using Python code. At last, we will also have a few code examples that can be used as a short reference.

Go <-> language X interop#

Because this library is meant to be a general library for other clients to use that are written in different programming languages, we need to find a way to make this Go library available on each platform and codebase. The approach that we take is to build a C library from the Go library using Cgo. Cgo can have its disadvantages with performance and the constant conversion between Go and C types. To overcome those barriers, this library has the following goals (with some others noted here):

And finally the most important goal:

Architecture#

In the previous section, we have already hinted a bit on the exact architecture. This section will expand upon it by giving a figure of the basic structure

overview of shared library structure

As can be seen by this architecture, there is an intermediate layer between the client and the shared library. This wrapper eases the way of loading this library and then defining a more language specific API for it. In the eduvpn-common repo, we currently only support a Python wrapper. Clients themselves can define their own wrapper

Typical flow for a client#

NOTE: This uses the function names that are defined in the exports file in Go. For your own wrapper/the Python wrapper they are different. But the general flow is the same

  1. The client starts up. It calls the Register function that communicates with the library that it has initialized
  2. It gets the list of servers using ServerList
  3. When the user selects a server to connect to in the UI, it calls the GetConfig to get a VPN configuration for this server. This function transitions the state machine multiple times. The client uses these state transitions for logging or even updating the UI. The client then connects

    • New feature in eduvpn-common: Check if the VPN can reach the gateway after the client is connected by calling StartFailover
  4. If the client has no servers, or it wants to add a new server, the client calls DiscoOrganizations and DiscoServers to get the discovery files from the library. This even returns cached copies if the organizations or servers should not have been updated according to the documentation

    • From this discovery list, it calls AddServer to add the server to the internal server list of eduvpn-common. This also calls necessary state transitions, e.g. for authorizing the server. The next call to ServerList then has this server included
    • It can then get a configuration for this server like we have explained in step 3
  5. When a configuration has been obtained, the internal state has changed and the client can get the current server that was configured using CurrentServer. CurrentServer can also be called after startup if a server was previously set as the current server

  6. When the VPN disconnects, the client calls Cleanup so that the server resources are cleaned up by calling the /disconnect endpoint
  7. A server can be removed with the RemoveServer function
  8. When the client is done, it calls Deregister such that the most up to date internal state is saved to disk. Note that eduvpn-common also saves the internal state .e.g. after obtaining a VPN configuration

Finite state machine#

The eduvpn-common library uses a finite state machine internally to keep track of which state the client is in and to communicate data callbacks (e.g. to communicate the Authorization URL in the OAuth process to the client).

FSM example#

The following is an example of the FSM when the client has obtained a Wireguard/OpenVPN configuration from an eduVPN server

finite state machine (FSM) of eduvpn-common

The current state is highlighted in the cyan color.

State explanation#

For the explanation of what all the different states mean, see the client documentation

States that ask data#

In eduvpn-common, there are certain states that require attention from the client.

The rest of the states are miscellaneous states, meaning that the client can handle them however it wants to. However, it can be useful to handle most state transitions to e.g. show loading screens or for logging and debugging purposes.

Code examples#

This chapter contains code examples that use the API

Go command line client#

The following is an example in the repository. It is a command line client.

{// Package main implements an example CLI client
package main

import (
    "context"
    "flag"
    "fmt"
    "os"
    "reflect"
    "strings"

    "codeberg.org/eduVPN/eduvpn-common/client"
    "codeberg.org/eduVPN/eduvpn-common/i18n"
    "codeberg.org/eduVPN/eduvpn-common/internal/version"
    "codeberg.org/eduVPN/eduvpn-common/types/cookie"
    srvtypes "codeberg.org/eduVPN/eduvpn-common/types/server"

    "github.com/pkg/browser"
)

// Open a browser with xdg-open.
func openBrowser(data any) {
    str, ok := data.(string)
    if !ok {
        return
    }
    go func() {
        err := browser.OpenURL(str)
        if err != nil {
            fmt.Fprintln(os.Stderr, "failed to open browser with error:", err)
            fmt.Println("Please open your browser manually")
        }
    }()
}

func getProfileInteractive(profiles *srvtypes.Profiles, data any) (string, error) {
    fmt.Printf("Multiple VPN profiles found. Please select a profile by entering e.g. 1")
    ps := ""
    var options []string
    i := 0
    for k, v := range profiles.Map {
        ps += fmt.Sprintf("\n%d - %s", i+1, i18n.GetLanguageMatched(v.DisplayName, "en"))
        options = append(options, k)
        i++
    }

    // Show the profiles
    fmt.Println(ps)

    var idx int
    if _, err := fmt.Scanf("%d", &idx); err != nil || idx <= 0 ||
        idx > len(profiles.Map) {
        fmt.Fprintln(os.Stderr, "invalid profile chosen, please retry")
        return getProfileInteractive(profiles, data)
    }

    p := options[idx-1]
    fmt.Println("Sending profile ID", p)
    return p, nil
}

func sendProfile(profile string, data any) {
    d, ok := data.(*srvtypes.RequiredAskTransition)
    if !ok {
        fmt.Fprintf(os.Stderr, "\ninvalid data type: %v\n", reflect.TypeOf(data))
        os.Exit(1)
    }
    sps, ok := d.Data.(*srvtypes.Profiles)
    if !ok {
        fmt.Fprintf(os.Stderr, "\ninvalid data type for profiles: %v\n", reflect.TypeOf(d.Data))
        os.Exit(1)
    }

    if profile == "" {
        gprof, err := getProfileInteractive(sps, data)
        if err != nil {
            fmt.Fprintf(os.Stderr, "failed getting profile interactively: %v\n", err)
            os.Exit(1)
        }
        profile = gprof
    }
    if err := d.C.Send(profile); err != nil {
        fmt.Fprintf(os.Stderr, "failed setting profile with error: %v\n", err)
        os.Exit(1)
    }
}

// The callback function
// If OAuth is started we open the browser with the Auth URL
// If we ask for a profile, we send the profile using command line input
// Note that this has an additional argument, the vpn state which was wrapped into this callback function below.
func stateCallback(_ client.FSMStateID, newState client.FSMStateID, data any, prof string, dir string) {
    if newState == client.StateOAuthStarted {
        openBrowser(data)
    }

    if newState == client.StateAskProfile {
        sendProfile(prof, data)
    }

    if newState == client.StateAskLocation {
        // removing is best effort
        _ = os.RemoveAll(dir)
        fmt.Fprint(os.Stderr, "An invalid secure location is stored. This CLI doesn't support interactively choosing a location yet. Give a correct location with the -country-code flag")
        os.Exit(1)
    }
}

// Get a config for Institute Access or Secure Internet Server.
func getConfig(state *client.Client, url string, srvType srvtypes.Type, cc string, prof string) (*srvtypes.Configuration, error) {
    if !strings.HasPrefix(url, "http") {
        url = "https://" + url
    }
    ck := cookie.NewWithContext(context.Background())
    defer ck.Cancel() //nolint:errcheck
    err := state.AddServer(ck, url, srvType, nil)
    if err != nil {
        // TODO: This is quite hacky :^)
        if !strings.Contains(err.Error(), "a secure internet server already exists.") {
            return nil, err
        }
    }
    if cc != "" {
        err = state.SetSecureLocation(url, cc)
        if err != nil {
            return nil, err
        }
    }

    if prof != "" {
        // this is best effort, e.g. if no server was chosen before this fails
        _ = state.SetProfileID(prof) //nolint:errcheck
    }

    return state.GetConfig(ck, url, srvType, false, false)
}

// Get a config for a single server, Institute Access or Secure Internet.
func printConfig(url string, cc string, srvType srvtypes.Type, prof string) error {
    var c *client.Client
    var err error
    var dir string
    dir, err = os.MkdirTemp("", "eduvpn-common")
    if err != nil {
        return err
    }
    // removing is best effort
    defer os.RemoveAll(dir) //nolint:errcheck
    c, err = client.New(
        "org.eduvpn.app.linux",
        fmt.Sprintf("%s-cli", version.Version),
        dir,
        func(oldState client.FSMStateID, newState client.FSMStateID, data any) bool {
            stateCallback(oldState, newState, data, prof, dir)
            return true
        },
    )
    if err != nil {
        return err
    }
    _ = c.Register()

    ck := cookie.NewWithContext(context.Background())
    _, err = c.DiscoOrganizations(ck, false, "")
    if err != nil {
        return err
    }
    _, err = c.DiscoServers(ck, false, "")
    if err != nil {
        return err
    }

    defer c.Deregister()

    cfg, err := getConfig(c, url, srvType, cc, prof)
    if err != nil {
        return err
    }
    fmt.Println(cfg.VPNConfig)
    return nil
}

// The main function
// It parses the arguments and executes the correct functions.
func main() {
    cu := flag.String("get-custom", "", "The url of a custom server to connect to")
    u := flag.String("get-institute", "", "The url of an institute to connect to")
    sec := flag.String("get-secure", "", "Gets secure internet servers")
    cc := flag.String("country-code", "", "The country code to use in case of a secure internet server")
    prof := flag.String("profile", "", "The profile ID to choose")
    flag.Parse()

    // Connect to a VPN by getting an Institute Access config
    var err error
    switch {
    case *cu != "":
        err = printConfig(*cu, "", srvtypes.TypeCustom, *prof)
    case *u != "":
        err = printConfig(*u, "", srvtypes.TypeInstituteAccess, *prof)
    case *sec != "":
        err = printConfig(*sec, *cc, srvtypes.TypeSecureInternet, *prof)
    default:
        flag.PrintDefaults()
    }

    if err != nil {
        fmt.Fprintf(os.Stderr, "failed to get a VPN config: %v\n", err)
    }
}}