1<!doctype html public "-//W3C//DTD HTML 4.01 Transitional//EN" 2 "http://www.w3.org/TR/html4/loose.dtd"> 3 4<html> 5 6<head> 7 8<title>Postfix LDAP Howto</title> 9 10<meta http-equiv="Content-Type" content="text/html; charset=us-ascii"> 11 12</head> 13 14<body> 15 16<h1><img src="postfix-logo.jpg" width="203" height="98" ALT="">Postfix LDAP Howto</h1> 17 18<hr> 19 20<h2>LDAP Support in Postfix</h2> 21 22<p> Postfix can use an LDAP directory as a source for any of its 23lookups: <a href="aliases.5.html">aliases(5)</a>, <a href="virtual.5.html">virtual(5)</a>, <a href="canonical.5.html">canonical(5)</a>, etc. This allows 24you to keep information for your mail service in a replicated 25network database with fine-grained access controls. By not storing 26it locally on the mail server, the administrators can maintain it 27from anywhere, and the users can control whatever bits of it you 28think appropriate. You can have multiple mail servers using the 29same information, without the hassle and delay of having to copy 30it to each. </p> 31 32<p> Topics covered in this document:</p> 33 34<ul> 35 36<li><a href="#build">Building Postfix with LDAP support</a> 37 38<li><a href="#config">Configuring LDAP lookups</a> 39 40<li><a href="#example_alias">Example: aliases</a> 41 42<li><a href="#example_virtual">Example: virtual domains/addresses</a> 43 44<li><a href="#example_group">Example: expanding LDAP groups</a> 45 46<li><a href="#other">Other uses of LDAP lookups</a> 47 48<li><a href="#hmmmm">Notes and things to think about</a> 49 50<li><a href="#feedback">Feedback</a> 51 52<li><a href="#credits">Credits</a> 53 54</ul> 55 56<h2><a name="build">Building Postfix with LDAP support</a></h2> 57 58<p> These instructions assume that you build Postfix from source 59code as described in the <a href="INSTALL.html">INSTALL</a> document. Some modification may 60be required if you build Postfix from a vendor-specific source 61package. </p> 62 63<p> Note 1: Postfix no longer supports the LDAP version 1 interface. 64</p> 65 66<p> Note 2: to use LDAP with Debian GNU/Linux's Postfix, all you 67need is to install the postfix-ldap package and you're done. There 68is no need to recompile Postfix. </p> 69 70<p> You need to have LDAP libraries and include files installed 71somewhere on your system, and you need to configure the Postfix 72Makefiles accordingly. </p> 73 74<p> For example, to build the OpenLDAP libraries for use with 75Postfix (i.e. LDAP client code only), you could use the following 76command: </p> 77 78<blockquote> 79<pre> 80% ./configure --without-kerberos --without-cyrus-sasl --without-tls \ 81 --without-threads --disable-slapd --disable-slurpd \ 82 --disable-debug --disable-shared 83</pre> 84</blockquote> 85 86<p> If you're using the libraries from the UM distribution 87(<a href="http://www.umich.edu/~dirsvcs/ldap/ldap.html">http://www.umich.edu/~dirsvcs/ldap/ldap.html</a>) or OpenLDAP 88(<a href="http://www.openldap.org">http://www.openldap.org</a>), something like this in the top level of 89your Postfix source tree should work: </p> 90 91<blockquote> 92<pre> 93% make tidy 94% make makefiles CCARGS="-I/usr/local/include -DHAS_LDAP" \ 95 AUXLIBS="-L/usr/local/lib -lldap -L/usr/local/lib -llber" 96</pre> 97</blockquote> 98 99<p> On Solaris 2.x you may have to specify run-time link information, 100otherwise ld.so will not find some of the shared libraries: </p> 101 102<blockquote> 103<pre> 104% make tidy 105% make makefiles CCARGS="-I/usr/local/include -DHAS_LDAP" \ 106 AUXLIBS="-L/usr/local/lib -R/usr/local/lib -lldap \ 107 -L/usr/local/lib -R/usr/local/lib -llber" 108</pre> 109</blockquote> 110 111<p> The 'make tidy' command is needed only if you have previously 112built Postfix without LDAP support. </p> 113 114<p> Instead of '/usr/local' specify the actual locations of your 115LDAP include files and libraries. Be sure to not mix LDAP include 116files and LDAP libraries of different versions!! </p> 117 118<p> If your LDAP libraries were built with Kerberos support, you'll 119also need to include your Kerberos libraries in this line. Note 120that the KTH Kerberos IV libraries might conflict with Postfix's 121lib/libdns.a, which defines dns_lookup. If that happens, you'll 122probably want to link with LDAP libraries that lack Kerberos support 123just to build Postfix, as it doesn't support Kerberos binds to the 124LDAP server anyway. Sorry about the bother. </p> 125 126<p> If you're using one of the Netscape LDAP SDKs, you'll need to 127change the AUXLIBS line to point to libldap10.so or libldapssl30.so 128or whatever you have, and you may need to use the appropriate linker 129option (e.g. '-R') so the executables can find it at runtime. </p> 130 131<p> If you are using OpenLDAP, and the libraries were built with SASL 132support, you can add -DUSE_LDAP_SASL to the CCARGS to enable SASL support. 133For example: </p> 134 135<blockquote> 136<pre> 137 CCARGS="-I/usr/local/include -DHAS_LDAP -DUSE_LDAP_SASL" 138</pre> 139</blockquote> 140 141<h2><a name="config">Configuring LDAP lookups</a></h2> 142 143<p> In order to use LDAP lookups, define an LDAP source 144as a table lookup in <a href="postconf.5.html">main.cf</a>, for example: </p> 145 146<blockquote> 147<pre> 148<a href="postconf.5.html#alias_maps">alias_maps</a> = hash:/etc/aliases, <a href="ldap_table.5.html">ldap</a>:/etc/postfix/ldap-aliases.cf 149</pre> 150</blockquote> 151 152<p> The file /etc/postfix/ldap-aliases.cf can specify a great number 153of parameters, including parameters that enable LDAP SSL or STARTTLS, 154and LDAP SASL. For a complete description, see the <a href="ldap_table.5.html">ldap_table(5)</a> 155manual page. </p> 156 157<h2><a name="example_alias">Example: local(8) aliases</a></h2> 158 159<p> Here's a basic example for using LDAP to look up <a href="local.8.html">local(8)</a> 160aliases. Assume that in <a href="postconf.5.html">main.cf</a>, you have: </p> 161 162<blockquote> 163<pre> 164<a href="postconf.5.html#alias_maps">alias_maps</a> = hash:/etc/aliases, <a href="ldap_table.5.html">ldap</a>:/etc/postfix/ldap-aliases.cf 165</pre> 166</blockquote> 167 168<p> and in <a href="ldap_table.5.html">ldap</a>:/etc/postfix/ldap-aliases.cf you have: </p> 169 170<blockquote> 171<pre> 172server_host = ldap.example.com 173search_base = dc=example, dc=com 174</pre> 175</blockquote> 176 177<p> Upon receiving mail for a local address "ldapuser" that isn't 178found in the /etc/aliases database, Postfix will search the LDAP 179server listening at port 389 on ldap.example.com. It will bind anonymously, 180search for any directory entries whose mailacceptinggeneralid 181attribute is "ldapuser", read the "maildrop" attributes of those 182found, and build a list of their maildrops, which will be treated 183as <a href="http://tools.ietf.org/html/rfc822">RFC822</a> addresses to which the message will be delivered. </p> 184 185<h2><a name="example_virtual">Example: virtual domains/addresses</a></h2> 186 187<p> If you want to keep information for virtual lookups in your 188directory, it's only a little more complicated. First, you need to 189make sure Postfix knows about the virtual domain. An easy way to 190do that is to add the domain to the mailacceptinggeneralid attribute 191of some entry in the directory. Next, you'll want to make sure all 192of your virtual recipient's mailacceptinggeneralid attributes are 193fully qualified with their virtual domains. Finally, if you want 194to designate a directory entry as the default user for a virtual 195domain, just give it an additional mailacceptinggeneralid (or the 196equivalent in your directory) of "@fake.dom". That's right, no 197user part. If you don't want a catchall user, omit this step and 198mail to unknown users in the domain will simply bounce. </p> 199 200<p> In summary, you might have a catchall user for a virtual domain 201that looks like this: </p> 202 203<blockquote> 204<pre> 205 dn: cn=defaultrecipient, dc=fake, dc=dom 206 objectclass: top 207 objectclass: virtualaccount 208 cn: defaultrecipient 209 owner: uid=root, dc=someserver, dc=isp, dc=dom 2101 -> mailacceptinggeneralid: fake.dom 2112 -> mailacceptinggeneralid: @fake.dom 2123 -> maildrop: realuser@real.dom 213</pre> 214</blockquote> 215 216<dl compact> 217 218<dd> <p> 1: Postfix knows fake.dom is a valid virtual domain when 219it looks for this and gets something (the maildrop) back. </p> 220 221<dd> <p> 2: This causes any mail for unknown users in fake.dom to 222go to this entry ... </p> 223 224<dd> <p> 3: ... and then to its maildrop. </p> 225 226</dl> 227 228<p> Normal users might simply have one mailacceptinggeneralid and 229<a href="QSHAPE_README.html#maildrop_queue">maildrop</a>, e.g. "normaluser@fake.dom" and "normaluser@real.dom". 230</p> 231 232<h2><a name="example_group">Example: expanding LDAP groups</a></h2> 233 234<p> 235LDAP is frequently used to store group member information. There are a 236number of ways of handling LDAP groups. We will show a few examples in 237order of increasing complexity, but owing to the number of independent 238variables, we can only present a tiny portion of the solution space. 239We show how to: 240</p> 241 242<ol> 243 244<li> <p> query groups as lists of addresses; </p> 245 246<li> <p> query groups as lists of user objects containing addresses; </p> 247 248<li> <p> forward special lists unexpanded to a separate list server, 249for moderation or other processing; </p> 250 251<li> <p> handle complex schemas by controlling expansion and by treating 252leaf nodes specially, using features that are new in Postfix 2.4. </p> 253 254</ol> 255 256<p> 257The example LDAP entries and implied schema below show two group entries 258("agroup" and "bgroup") and four user entries ("auser", "buser", "cuser" 259and "duser"). The group "agroup" has the users "auser" (1) and "buser" (2) 260as members via DN references in the multi-valued attribute "memberdn", and 261direct email addresses of two external users "auser@example.org" (3) and 262"buser@example.org" (4) stored in the multi-valued attribute "memberaddr". 263The same is true of "bgroup" and "cuser"/"duser" (6)/(7)/(8)/(9), but 264"bgroup" also has a "maildrop" attribute of "bgroup@mlm.example.com" 265(5): </p> 266 267<blockquote> 268<pre> 269 dn: cn=agroup, dc=example, dc=com 270 objectclass: top 271 objectclass: ldapgroup 272 cn: agroup 273 mail: agroup@example.com 2741 -> memberdn: uid=auser, dc=example, dc=com 2752 -> memberdn: uid=buser, dc=example, dc=com 2763 -> memberaddr: auser@example.org 2774 -> memberaddr: buser@example.org 278</pre> 279<br> 280 281<pre> 282 dn: cn=bgroup, dc=example, dc=com 283 objectclass: top 284 objectclass: ldapgroup 285 cn: bgroup 286 mail: bgroup@example.com 2875 -> maildrop: bgroup@mlm.example.com 2886 -> memberdn: uid=cuser, dc=example, dc=com 2897 -> memberdn: uid=duser, dc=example, dc=com 2908 -> memberaddr: cuser@example.org 2919 -> memberaddr: duser@example.org 292</pre> 293<br> 294 295<pre> 296 dn: uid=auser, dc=example, dc=com 297 objectclass: top 298 objectclass: ldapuser 299 uid: auser 30010 -> mail: auser@example.com 30111 -> maildrop: auser@mailhub.example.com 302</pre> 303<br> 304 305<pre> 306 dn: uid=buser, dc=example, dc=com 307 objectclass: top 308 objectclass: ldapuser 309 uid: buser 31012 -> mail: buser@example.com 31113 -> maildrop: buser@mailhub.example.com 312</pre> 313<br> 314 315<pre> 316 dn: uid=cuser, dc=example, dc=com 317 objectclass: top 318 objectclass: ldapuser 319 uid: cuser 32014 -> mail: cuser@example.com 321</pre> 322<br> 323 324<pre> 325 dn: uid=duser, dc=example, dc=com 326 objectclass: top 327 objectclass: ldapuser 328 uid: duser 32915 -> mail: duser@example.com 330</pre> 331<br> 332 333</blockquote> 334 335<p> Our first use case ignores the "memberdn" attributes, and assumes 336that groups hold only direct "memberaddr" strings as in (3), (4), (8) and 337(9). The goal is to map the group address to the list of constituent 338"memberaddr" values. This is simple, ignoring the various connection 339related settings (hosts, ports, bind settings, timeouts, ...) we have: 340</p> 341 342<blockquote> 343<pre> 344 simple.cf: 345 ... 346 search_base = dc=example, dc=com 347 query_filter = mail=%s 348 result_attribute = memberaddr 349 $ postmap -q agroup@example.com <a href="ldap_table.5.html">ldap</a>:/etc/postfix/simple.cf \ 350 auser@example.org,buser@example.org 351</pre> 352</blockquote> 353 354<p> We search "dc=example, dc=com". The "mail" attribute is used in the 355query_filter to locate the right group, the "result_attribute" setting 356described in <a href="ldap_table.5.html">ldap_table(5)</a> is used to specify that "memberaddr" values 357from the matching group are to be returned as a comma separated list. 358Always check tables using <a href="postmap.1.html">postmap(1)</a> with the "-q" option, before 359deploying them into production use in <a href="postconf.5.html">main.cf</a>. </p> 360 361<p> Our second use case instead expands "memberdn" attributes (1), (2), 362(6) and (7), follows the DN references and returns the "maildrop" of the 363referenced user entries. Here we use the "special_result_attribute" 364setting from <a href="ldap_table.5.html">ldap_table(5)</a> to designate the "memberdn" attribute 365as holding DNs of the desired member entries. The "result_attribute" 366setting selects which attributes are returned from the selected DNs. It 367is important to choose a result attribute that is not also present in 368the group object, because result attributes are collected from both 369the group and the member DNs. In this case we choose "maildrop" and 370assume for the moment that groups never have a "maildrop" (the "bgroup" 371"maildrop" attribute is for a different use case). The returned data for 372"auser" and "buser" is from items (11) and (13) in the example data. </p> 373 374<blockquote> 375<pre> 376 special.cf: 377 ... 378 search_base = dc=example, dc=com 379 query_filter = mail=%s 380 result_attribute = maildrop 381 special_result_attribute = memberdn 382 $ postmap -q agroup@example.com <a href="ldap_table.5.html">ldap</a>:/etc/postfix/special.cf \ 383 auser@mailhub.example.com,buser@mailhub.example.com 384</pre> 385</blockquote> 386 387<p> Note: if the desired member object result attribute is always also 388present in the group, you get surprising results: the expansion also 389returns the address of the group. This is a known limitation of Postfix 390releases prior to 2.4, and is addressed in the new with Postfix 2.4 391"leaf_result_attribute" feature described in <a href="ldap_table.5.html">ldap_table(5)</a>. </p> 392 393<p> Our third use case has some groups that are expanded immediately, 394and other groups that are forwarded to a dedicated mailing list manager 395host for delayed expansion. This uses two LDAP tables, one for users 396and forwarded groups and a second for groups that can be expanded 397immediately. It is assumed that groups that require forwarding are 398never nested members of groups that are directly expanded. </p> 399 400<blockquote> 401<pre> 402 no_expand.cf: 403 ... 404 search_base = dc=example, dc=com 405 query_filter = mail=%s 406 result_attribute = maildrop 407 expand.cf 408 ... 409 search_base = dc=example, dc=com 410 query_filter = mail=%s 411 result_attribute = maildrop 412 special_result_attribute = memberdn 413 $ postmap -q auser@example.com \ 414 <a href="ldap_table.5.html">ldap</a>:/etc/postfix/no_expand.cf <a href="ldap_table.5.html">ldap</a>:/etc/postfix/expand.cf \ 415 auser@mailhub.example.com 416 $ postmap -q agroup@example.com \ 417 <a href="ldap_table.5.html">ldap</a>:/etc/postfix/no_expand.cf <a href="ldap_table.5.html">ldap</a>:/etc/postfix/expand.cf \ 418 auser@mailhub.example.com,buser@mailhub.example.com 419 $ postmap -q bgroup@example.com \ 420 <a href="ldap_table.5.html">ldap</a>:/etc/postfix/no_expand.cf <a href="ldap_table.5.html">ldap</a>:/etc/postfix/expand.cf \ 421 bgroup@mlm.example.com 422</pre> 423</blockquote> 424 425<p> Non-group objects and groups with delayed expansion (those that have a 426maildrop attribute) are rewritten to a single maildrop value. Groups that 427don't have a maildrop are expanded as the second use case. This admits 428a more elegant solution with Postfix 2.4 and later. </p> 429 430<p> Our final use case is the same as the third, but this time uses new 431features in Postfix 2.4. We now are able to use just one LDAP table and 432no longer need to assume that forwarded groups are never nested inside 433expanded groups. </p> 434 435<blockquote> 436<pre> 437 fancy.cf: 438 ... 439 search_base = dc=example, dc=com 440 query_filter = mail=%s 441 result_attribute = memberaddr 442 special_result_attribute = memberdn 443 terminal_result_attribute = maildrop 444 leaf_result_attribute = mail 445 $ postmap -q auser@example.com <a href="ldap_table.5.html">ldap</a>:/etc/postfix/fancy.cf \ 446 auser@mailhub.example.com 447 $ postmap -q cuser@example.com <a href="ldap_table.5.html">ldap</a>:/etc/postfix/fancy.cf \ 448 cuser@example.com 449 $ postmap -q agroup@example.com <a href="ldap_table.5.html">ldap</a>:/etc/postfix/fancy.cf \ 450 auser@mailhub.example.com,buser@mailhub.example.com,auser@example.org,buser@example.org 451 $ postmap -q bgroup@example.com <a href="ldap_table.5.html">ldap</a>:/etc/postfix/fancy.cf \ 452 bgroup@mlm.example.com 453</pre> 454</blockquote> 455 456<p> Above, delayed expansion is enabled via "terminal_result_attribute", 457which, if present, is used as the sole result and all other expansion is 458suppressed. Otherwise, the "leaf_result_attribute" is only returned for 459leaf objects that don't have a "special_result_attribute" (non-groups), 460while the "result_attribute" (direct member address of groups) is returned 461at every level of recursive expansion, not just the leaf nodes. This fancy 462example illustrates all the features of Postfix 2.4 group expansion. </p> 463 464<h2><a name="other">Other uses of LDAP lookups</a></h2> 465 466Other common uses for LDAP lookups include rewriting senders and 467recipients with Postfix's canonical lookups, for example in order 468to make mail leaving your site appear to be coming from 469"First.Last@example.com" instead of "userid@example.com". 470 471<h2><a name="hmmmm">Notes and things to think about</a></h2> 472 473<ul> 474 475<li> <p> The bits of schema and attribute names used in this document are just 476 examples. There's nothing special about them, other than that some are 477 the defaults in the LDAP configuration parameters. You can use 478 whatever schema you like, and configure Postfix accordingly. </p> 479 480<li> <p> You probably want to make sure that mailacceptinggeneralids are 481 unique, and that not just anyone can specify theirs as postmaster or 482 root, say. </p> 483 484<li> <p> An entry can have an arbitrary number of mailacceptinggeneralids or 485 maildrops. Maildrops can also be comma-separated lists of addresses. 486 They will all be found and returned by the lookups. For example, you 487 could define an entry intended for use as a mailing list that looks 488 like this (Warning! Schema made up just for this example): </p> 489 490<blockquote> 491<pre> 492dn: cn=Accounting Staff List, dc=example, dc=com 493cn: Accounting Staff List 494o: example.com 495objectclass: maillist 496mailacceptinggeneralid: accountingstaff 497mailacceptinggeneralid: accounting-staff 498maildrop: mylist-owner 499maildrop: an-accountant 500maildrop: some-other-accountant 501maildrop: this, that, theother 502</pre> 503</blockquote> 504 505<li> <p> If you use an LDAP map for lookups other than aliases, you may have to 506 make sure the lookup makes sense. In the case of virtual lookups, 507 maildrops other than mail addresses are pretty useless, because 508 Postfix can't know how to set the ownership for program or file 509 delivery. Your <b>query_filter</b> should probably look something like this: </p> 510 511<blockquote> 512<pre> 513query_filter = (&(mailacceptinggeneralid=%s)(!(|(maildrop="*|*")(maildrop="*:*")(maildrop="*/*")))) 514</pre> 515</blockquote> 516 517<li> <p> And for that matter, even for aliases, you may not want users able to 518 specify their maildrops as programs, includes, etc. This might be 519 particularly pertinent on a "sealed" server where they don't have 520 local UNIX accounts, but exist only in LDAP and Cyrus. You might allow 521 the fun stuff only for directory entries owned by an administrative 522 account, 523 so that if the object had a program as its maildrop and weren't owned 524 by "cn=root" it wouldn't be returned as a valid local user. This will 525 require some thought on your part to implement safely, considering the 526 ramifications of this type of delivery. You may decide it's not worth 527 the bother to allow any of that nonsense in LDAP lookups, ban it in 528 the <b>query_filter</b>, and keep things like majordomo lists in local alias 529 databases. </p> 530 531<blockquote> 532<pre> 533query_filter = (&(mailacceptinggeneralid=%s)(!(|(maildrop="*|*")(maildrop="*:*")(maildrop="*/*"))(owner=cn=root, dc=your, dc=com))) 534</pre> 535</blockquote> 536 537<li> <p> LDAP lookups are slower than local DB or DBM lookups. For most sites 538 they won't be a bottleneck, but it's a good idea to know how to tune 539 your directory service. </p> 540 541<li> <p> Multiple LDAP maps share the same LDAP connection if they differ 542 only in their query related parameters: base, scope, query_filter, and 543 so on. To take advantage of this, avoid spurious differences in the 544 definitions of LDAP maps: host selection order, version, bind, tls 545 parameters, ... should be the same for multiple maps whenever possible. </p> 546 547</ul> 548 549<h2><a name="feedback">Feedback</a></h2> 550 551<p> If you have questions, send them to postfix-users@postfix.org. Please 552include relevant information about your Postfix setup: LDAP-related 553output from postconf, which LDAP libraries you built with, and which 554directory server you're using. If your question involves your directory 555contents, please include the applicable bits of some directory entries. </p> 556 557<h2><a name="credits">Credits</a></h2> 558 559<ul> 560 561<li>Manuel Guesdon: Spotted a bug with the timeout attribute. 562 563<li>John Hensley: Multiple LDAP sources with more configurable attributes. 564 565<li>Carsten Hoeger: Search scope handling. 566 567<li>LaMont Jones: Domain restriction, URL and DN searches, multiple result 568 attributes. 569 570<li>Mike Mattice: Alias dereferencing control. 571 572<li>Hery Rakotoarisoa: Patches for LDAPv3 updating. 573 574<li>Prabhat K Singh: Wrote the initial Postfix LDAP lookups and connection caching. 575 576<li>Keith Stevenson: <a href="http://tools.ietf.org/html/rfc2254">RFC 2254</a> escaping in queries. 577 578<li>Samuel Tardieu: Noticed that searches could include wildcards, prompting 579 the work on <a href="http://tools.ietf.org/html/rfc2254">RFC 2254</a> escaping in queries. Spotted a bug 580 in binding. 581 582<li>Sami Haahtinen: Referral chasing and v3 support. 583 584<li>Victor Duchovni: ldap_bind() timeout. With fixes from LaMont Jones: 585 OpenLDAP cache deprecation. Limits on recursion, expansion 586 and search results size. LDAP connection sharing for maps 587 differing only in the query parameters. 588 589<li>Liviu Daia: Support for SSL/STARTTLS. Support for storing map definitions in 590 external files (<a href="ldap_table.5.html">ldap</a>:/path/ldap.cf) needed to securely store 591 passwords for plain auth. 592 593<li>Liviu Daia revised the configuration interface and added the <a href="postconf.5.html">main.cf</a> 594 configuration feature.</li> 595 596<li>Liviu Daia with further refinements from Jose Luis Tallon and 597Victor Duchovni developed the common query, result_format, domain and 598expansion_limit interface for LDAP, MySQL and PosgreSQL.</li> 599 600<li>Gunnar Wrobel provided a first implementation of a feature to 601limit LDAP search results to leaf nodes only. Victor generalized 602this into the Postfix 2.4 "leaf_result_attribute" feature. </li> 603 604<li>Quanah Gibson-Mount contributed support for advanced LDAP SASL 605mechanisms, beyond the password-based LDAP "simple" bind. </li> 606 607</ul> 608 609And of course Wietse. 610 611</body> 612 613</html> 614