1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
|
# Let's build a client using Python
To begin, let's follow the flow and see if we can figure out how it works.
## Registering
> The client starts up. It calls the Register function that communicates with the library that it has initialized
In Python, this works like the following:
- First import the library
```python
import eduvpn_common.main as edu
class Transitions:
def __init__(self, common):
self.common = common
# These arguments can be found in the docstring
# But also in the exports.go file
# For Python it's a bit different, we have split the arguments into the constructor and register
# Here we pass the client ID for OAuth, the version of the client and the directory where config files should be found
common=edu.EduVPN("org.eduvpn.app.linux", "0.0.1", "/tmp/test")
common.register(debug=True)
# we will come back to this later
transitions = Transitions(common)
common.register_class_callbacks(transitions)
```
Now after registering, we know that we have no servers configured (unless you're following this tutorial again with an existing `/tmp/test`). So we continue with step 4
## Discovery
> 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.
```python
# Let's get them and print them
print(common.get_disco_organizations())
print(common.get_disco_servers())
```
We get a big JSON blob, so which format is this? From the Go documentation:
> DiscoOrganizations gets the organizations from discovery, returned as types/discovery/discovery.go Organizations marshalled as JSON
> DiscoServers gets the servers from discovery, returned as types/discovery/discovery.go Servers marshalled as JSON
If you follow these files, you see two structs, Servers and Organizations. These structs have json tags associated with them. You can use this structure to figure out how to parse the returned data. In case of discovery, it's very similar to the [JSON files from the discovery server](https://disco.eduvpn.org/v2).
## Adding a server
The next bullet point that we implement is the following:
> 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
The discovery servers contains a server called the demo server. Let's try to add it. To add it we need to pass the type of server we're adding. From discovery we can deduce that this is an institute access server as the JSON looks like the following:
```json
{
"authentication_url_template": "",
"base_url": "https://demo.eduvpn.nl/",
"display_name": {
"en": "Demo"
},
"server_type": "institute_access", # this is why we know it is Institute Access
"support_contact": [
"mailto:eduvpn@surf.nl"
]
},
```
From the Go documentation, we know that the identifier must be the Base URL:
> id is the identifier of the string
> - In case of secure internet: The organization ID
> - In case of custom server: The base URL
> - In case of institute access: The base URL
```python
# Compare this to the Go version, the non-interactive field is optional here as it is default False
common.add_server(edu.ServerType.INSTITUTE_ACCESS, "https://demo.eduvpn.nl/")
```
But we get an error!
```bash
eduvpn_common.main.WrappedError: fsm failed transition from 'Chosen_Server' to 'OAuth_Started', is this required transition handled?
```
This is the state machine we briefly mentioned before. Some functions require that you handle certain transitions. From the Go documentation, we can find this in the documentation as well that you must handle this transition. Let's handle it in Python to open the webbrowser for the OAuth process.
We do this with the python wrapper by defining a class of state transitions. This class was already added and registered with `register_class_callbacks`. However, there was no transition added. Let's add it
```python
import webbrowser
from eduvpn_common.event import class_state_transition
from eduvpn_common.state import State, StateType
class Transitions:
def __init__(self, common):
self.common = common
@class_state_transition(State.OAUTH_STARTED, StateType.ENTER)
def enter_oauth(self, old_state: State, url: str):
webbrowser.open(url)
```
Now if you re-rerun the whole code with this transition added, your webbrowser should open.
Note that this state transition is essentially the same as the following code:
```python
-def handler(old: int, new: int, data: str):
- # it's 6 because https://codeberg.org/eduVPN/eduvpn-common/src/commit/b660911b5db000b43970f3754b5767bb50741360/client/fsm.go#L33
- if new == 6:
- webbrowser.open(data)
- return True
- return False
```
This is the code that is passed to the Go library. It handles certain states and returns `False` (zero) if a state is not handled, `True` (non-zero) if it is. If you define your own wrapper you should build an abstraction layer that resolves to a handler similar as above. This handler should be passed as a C function to the Go library when registering.
After you have authorized the application through the portal using the webbrowser, the server should have been added:
```python
print(common.get_servers())
```
Returns:
```json
{
"institute_access_servers": [
{
"display_name": {
"en": "Demo"
},
"identifier": "https://demo.eduvpn.nl/",
"profiles": {
"current": ""
},
"delisted": false
}
]
}
```
The format of this JSON is specified in the Go documentation:
`(in exports/exports.go)`
> It returns the server list as a JSON string defined in types/server/server.go List
## Obtaining a VPN configuration from the server
The next part of the flow is:
> 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
Let's try it, the required arguments are the same for adding a config in the Python wrapper:
```python
print(common.get_config(edu.ServerType.INSTITUTE_ACCESS, "https://demo.eduvpn.nl"))
```
However, this gives an exception:
```bash
eduvpn_common.main.WrappedError: fsm failed transition from 'Request_Config' to 'Ask_Profile', is this required transition handled?
```
A similar error to the OAuth error we had before. This `Ask_Profile` transition is there for the client/user to choose a profile as this server has multiple profiles defined.
To handle this transition and thus choose a profile to continue, we must do multiple steps:
- Add the condition to the transitions class
- Parse the data that we get back
- Reply with a choice for the profile
If we add the condition and print the data:
```python
@class_state_transition(State.ASK_PROFILE, StateType.ENTER)
def enter_ask_profile(self, old_state: State, data: str):
print("profiles:", data)
```
we get back the following JSON (from the Go docs: `The data for this transition is defined in types/server/server.go RequiredAskTransition with embedded data Profiles in types/server/server.go`):
```python
{
"cookie": 4,
"data": {
"map": {
"internet": {
"display_name": {
"en": "Internet"
},
"supported_protocols": [
1,
2
]
},
"internet-split": {
"display_name": {
"en": "No rfc1918 routes"
},
"supported_protocols": [
1,
2
]
}
},
"current": ""
}
}
```
This thus gives you the list of profiles with a so-called "cookie". This *cookie* is used to confirm the choice to the Go library. To do so we must do the following to handle this:
```python
import json
# Do this inside the Transitions class
@class_state_transition(State.ASK_PROFILE, StateType.ENTER)
def enter_ask_profile(self, old_state: State, data: str):
# parse the json
json_dict = json.loads(data)
self.common.cookie_reply(json_dict["cookie"], "internet")
```
If we then re-run the code, we get back the following JSON (from the Go docs: `The return data is the configuration, marshalled as JSON and defined in types/server/server.go Configuration`)
```python
{
"config": "the WireGuard config",
"protocol": 2, # 2 specifies WireGuard
"default_gateway": true
}
```
## Cleanup
The flow also mentioned:
> 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
Let's be a nice client and do this:
```python
common.deregister()
```
If we then call any function, we get an error, so it is important that you do this on exit:
```python
print(common.get_servers())
>>> eduvpn_common.main.WrappedError: No state available, did you register the client?
```
But when we register again and then get the list of servers, the servers are retrieved from disk:
```python
common=edu.EduVPN("org.eduvpn.app.linux", "0.0.1", "/tmp/test")
common.register(debug=True)
print(common.get_servers())
```
gives
```json
{
"institute_access_servers": [
{
"display_name": {
"en": "Demo"
},
"identifier": "https://demo.eduvpn.nl/",
"profiles": {
"map": {
"internet": {
"display_name": {
"en": "Internet"
},
"supported_protocols": [
1,
2
]
},
"internet-split": {
"display_name": {
"en": "No rfc1918 routes"
},
"supported_protocols": [
1,
2
]
}
},
"current": "internet"
},
"delisted": false
}
]
}
```
Note the difference with the previous JSON, the profiles are now initialized because we have gotten a configuration before.
If the `/tmp/test` directory is removed (the argument that was passed to register), we get no servers again:
```python
import shutil
shutil.rmtree("/tmp/test")
common=edu.EduVPN("org.eduvpn.app.linux", "0.0.1", "/tmp/test")
common.register(debug=True)
print(common.get_servers())
```
gives `"{}"`, an empty JSON object string
|