Skip to content

Commit 7813966

Browse files
authored
✨ Add basic server and simple database loaded from jsonnet (#3)
Add a basic server that can serve the 'Bind' and `Search` LDAP functions. The Bind method simply logs its parameters in order to see exactly how they arrive, testing using `ldapsearch`. The logging will be replace with actual authentication when implemented. The Search method is backed by a simple database. The database is loaded from a jsonnet file (which can be just json if desired). Add anonymous binds, and a stub for password binds. The database is currently case-sensitive, and is not backed by a schema. It is a tree made out of `DITNode` (Directory Information Tree nodes), each having an `Entry` and child nodes. Each `Entry` has a Distinguished Name (DN) and a map of attribute names to slice of values. Values are of type string only. This is a far cry from a proper LDAP database, but serves well as a stand-in during development. Future changes may add case-insensitive attribute names, and once a schema is added, the attribute values can be typed and possibly case-insensitive too. Disable a couple of gocritic linters because they're stupid. Pull-request: #3
1 parent 045b277 commit 7813966

20 files changed

+984
-8
lines changed

.golangci.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,9 @@ linters:
4242
- wastedassign
4343
- wrapcheck
4444
- wsl
45+
46+
linters-settings:
47+
gocritic:
48+
disabled-checks:
49+
- appendAssign # Perfectly valid to assign to a different var
50+
- ifElseChain # This is taste and cannot be determined automatically

Makefile

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
# --- Global -------------------------------------------------------------------
55
O = out
6-
COVERAGE = 0
6+
COVERAGE = 52
77
VERSION ?= $(shell git describe --tags --dirty --always)
88

99
## Build and lint
@@ -46,6 +46,26 @@ tidy:
4646

4747
.PHONY: build tidy
4848

49+
# --- Test ---------------------------------------------------------------------
50+
COVERFILE = $(O)/coverage.txt
51+
52+
## Run tests and generate a coverage file
53+
test: | $(O)
54+
go test -coverprofile=$(COVERFILE) ./...
55+
56+
## Check that test coverage meets the required level
57+
check-coverage: test
58+
@go tool cover -func=$(COVERFILE) | $(CHECK_COVERAGE) || $(FAIL_COVERAGE)
59+
60+
## Show test coverage in your browser
61+
cover: test
62+
go tool cover -html=$(COVERFILE)
63+
64+
CHECK_COVERAGE = awk -F '[ \t%]+' '/^total:/ {print; if ($$3 < $(COVERAGE)) exit 1}'
65+
FAIL_COVERAGE = { echo '$(COLOUR_RED)FAIL - Coverage below $(COVERAGE)%$(COLOUR_NORMAL)'; exit 1; }
66+
67+
.PHONY: check-coverage cover test
68+
4969
# --- Lint ---------------------------------------------------------------------
5070
## Lint go source code
5171
lint:

db.go

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"iter"
6+
"slices"
7+
"strconv"
8+
"strings"
9+
)
10+
11+
// DB is an LDAP database, which is just a collection of entries and indicies
12+
// over that collection. The DIT is the hierarchy of entries based on each
13+
// entries DN.
14+
type DB struct {
15+
DIT DITNode
16+
}
17+
18+
// Entry is a single ldap entry comprising a Distinguished Name (DN) and named
19+
// attributes that have multiple values. In an LDAP entry, attributes can
20+
// appear multiple times, requiring a slice of values for each named attribute.
21+
type Entry struct {
22+
DN DN
23+
Attrs map[string][]string
24+
}
25+
26+
// GetAttr returns the attribute values for the given attribute name and true
27+
// if the attribute exists, or an empty slice and false if it does not.
28+
func (e *Entry) GetAttr(attr string) ([]string, bool) {
29+
// TODO(camh): Make lookup case-insensitive
30+
// TODO(camh): Support lookup by OID
31+
v, ok := e.Attrs[attr]
32+
return v, ok
33+
}
34+
35+
// DITNode is a node in the Directory Information Tree (DIT), the hierarchical
36+
// index of entries indexed by DN. Often an LDAP search is performed relative
37+
// to a BaseDN. The DIT allows a search to be constrained to a sub-tree of the
38+
// total DIT.
39+
type DITNode struct {
40+
Entry *Entry
41+
children []*DITNode
42+
}
43+
44+
// DN is a decomposed DN string, where each element of the DN is broken out. DN
45+
// strings are comma-separated DN string components. The order of the
46+
// components is tree-traversal order - i.e. top-level first, so the DN
47+
// ou=people,dc=example,dc=com is ["dc=com", "dc=example", "ou=people"].
48+
type DN []string
49+
50+
// NewDB returns a new DB with a single root entry for the DIT for the server's
51+
// Directory Server Entry ([DSE]) (or is it DSA-Specific Entry?).
52+
//
53+
// For now, we do not populate it with any attributes.
54+
//
55+
// [DSE}: https://ldap.com/dit-and-the-ldap-root-dse/
56+
func NewDB() *DB {
57+
dse := DITNode{
58+
Entry: &Entry{
59+
DN: DN{},
60+
Attrs: map[string][]string{
61+
"objectClass": {"top"},
62+
},
63+
},
64+
}
65+
return &DB{DIT: dse}
66+
}
67+
68+
// AddEntries adds the given entries to the database. If the database has any
69+
// entries with the same DN as any of the ones being added, an error is
70+
// returned. Any entries prior to the one with the duplicate DN will be added
71+
// to the database.
72+
func (db *DB) AddEntries(entries []*Entry) error {
73+
for _, e := range entries {
74+
if err := db.DIT.insert(e); err != nil {
75+
return err
76+
}
77+
}
78+
return nil
79+
}
80+
81+
func (dit *DITNode) insert(entry *Entry) error {
82+
// Duplicate DN
83+
if entry.DN.Equal(dit.Entry.DN) {
84+
return fmt.Errorf("duplicate DN: %s", entry.DN)
85+
}
86+
87+
// We are below a child of the current node
88+
for _, child := range dit.children {
89+
if child.Entry.DN.IsAncestor(entry.DN) {
90+
return child.insert(entry)
91+
}
92+
}
93+
94+
// We are a child of the current node and maybe take over some of
95+
// its children we are their ancestor.
96+
newnode := &DITNode{Entry: entry}
97+
siblings := []*DITNode{}
98+
for _, child := range dit.children {
99+
if entry.DN.IsAncestor(child.Entry.DN) {
100+
newnode.children = append(newnode.children, child)
101+
} else {
102+
siblings = append(siblings, child)
103+
}
104+
}
105+
dit.children = append(siblings, newnode)
106+
107+
return nil
108+
}
109+
110+
// Find searches the DIT for an entry with the given DN and returns it. If no
111+
// entry matches, nil is returned.
112+
func (dit *DITNode) Find(dn DN) *DITNode {
113+
if dit.Entry.DN.Equal(dn) {
114+
return dit
115+
}
116+
if !dit.Entry.DN.IsAncestor(dn) {
117+
return nil
118+
}
119+
for _, child := range dit.children {
120+
if child.Entry.DN.IsAncestor(dn) {
121+
return child.Find(dn)
122+
}
123+
}
124+
return nil
125+
}
126+
127+
func (dit *DITNode) String() string {
128+
return dit.str(DN{}, 0)
129+
}
130+
131+
func (dit *DITNode) str(parent DN, level int) string {
132+
indent := 0
133+
s := ""
134+
if !dit.Entry.DN.IsRoot() {
135+
s = fmt.Sprintf("%*s%s\n", level, "", dit.Entry.DN.Tail(parent))
136+
indent = 2
137+
}
138+
for _, child := range dit.children {
139+
s += child.str(dit.Entry.DN, level+indent)
140+
}
141+
return s
142+
}
143+
144+
// Self returns an interator that yields just the node of the DIT on which it
145+
// is called.
146+
func (dit *DITNode) Self() iter.Seq[*DITNode] {
147+
return func(yield func(*DITNode) bool) {
148+
yield(dit)
149+
}
150+
}
151+
152+
// Children returns an iterator that yields all the direct children of the DIT
153+
// node on which it is called.
154+
func (dit *DITNode) Children() iter.Seq[*DITNode] {
155+
return slices.Values(dit.children)
156+
}
157+
158+
// All returns an iterator that yields the node of the DIT on which it is
159+
// called and all of its descendents.
160+
func (dit *DITNode) All() iter.Seq[*DITNode] {
161+
return func(yield func(*DITNode) bool) {
162+
dit.walk(yield)
163+
}
164+
}
165+
166+
func (dit *DITNode) walk(yield func(*DITNode) bool) bool {
167+
if dit.Entry != nil {
168+
if !yield(dit) {
169+
return false
170+
}
171+
}
172+
for _, c := range dit.children {
173+
if !c.walk(yield) {
174+
return false
175+
}
176+
}
177+
return true
178+
}
179+
180+
// NewDN constructs a DN from the given string representing a DN.
181+
func NewDN(dnstr string) DN {
182+
dnstr = strings.TrimSpace(dnstr)
183+
if dnstr == "" {
184+
return []string{}
185+
}
186+
dn := strings.Split(dnstr, ",")
187+
result := make(DN, 0, len(dn))
188+
for _, rdn := range dn {
189+
if attr, val, ok := strings.Cut(rdn, "="); ok {
190+
rdn = strings.TrimSpace(attr) + "=" + strings.TrimSpace(val)
191+
}
192+
result = append(result, rdn)
193+
// TODO(camh): backslash de-escaping
194+
// TODO(camh): multi-valued RDNs
195+
}
196+
197+
slices.Reverse(result)
198+
return result
199+
}
200+
201+
// String formats dn into a string representation of the DN and returns it.
202+
func (dn DN) String() string {
203+
// TODO(camh): escape chars (RFC 4514)
204+
clone := slices.Clone(dn)
205+
slices.Reverse(clone)
206+
return strings.Join(clone, ",")
207+
}
208+
209+
// IsAncestor returns whether sub is an ancestor of dn. A sub is an ancestor
210+
// of dn if dn matches the leading elements of sub. A DN is an ancestor of itself.
211+
func (dn DN) IsAncestor(sub DN) bool {
212+
if len(dn) > len(sub) {
213+
return false
214+
}
215+
for i := range dn {
216+
if dn[i] != sub[i] {
217+
return false
218+
}
219+
}
220+
return true
221+
}
222+
223+
// Equal returns true if dn is equal to rhs.
224+
func (dn DN) Equal(rhs DN) bool {
225+
return slices.Equal(dn, rhs)
226+
}
227+
228+
// CommonAncestor returns a DN that has the common ancestor of dn and other.
229+
// If there is no common ancestor, the root DN is returned.
230+
func (dn DN) CommonAncestor(other DN) DN {
231+
common := make(DN, 0, min(len(dn), len(other)))
232+
for i := range cap(common) {
233+
if dn[i] != other[i] {
234+
break
235+
}
236+
common = append(common, dn[i])
237+
}
238+
return common
239+
}
240+
241+
// Tail returns the parts of dn after the common ancestor of dn and head.
242+
func (dn DN) Tail(head DN) DN {
243+
c := dn.CommonAncestor(head)
244+
return dn[len(c):]
245+
}
246+
247+
// IsRoot returns true if dn is the root DN. The root DN has no components.
248+
func (dn DN) IsRoot() bool {
249+
return len(dn) == 0
250+
}
251+
252+
// NewEntryFromMap returns an Entry from the elements in attrs. It is intended
253+
// to build an entry from a JSON or similar representation - a string-encoded
254+
// map of attribute names to slice of values.
255+
//
256+
// The provided attrs must contain "objectClass" and "dn" elements at a
257+
// minimum. The values need not be an array but may be. The values must be
258+
// strings, float64s or bools.
259+
func NewEntryFromMap(attrs map[string]any) (*Entry, error) {
260+
// Validate that the entry contains at least an objectClass
261+
// and a dn attribute.
262+
if attrs["objectClass"] == nil || attrs["dn"] == nil {
263+
return nil, fmt.Errorf("value missing mandatory attributes: %#v", attrs)
264+
}
265+
266+
e := Entry{
267+
Attrs: make(map[string][]string),
268+
}
269+
270+
for attr, val := range attrs {
271+
attrVal := e.Attrs[attr]
272+
array, ok := val.([]any)
273+
if !ok {
274+
array = []any{val}
275+
}
276+
277+
if attr == "dn" {
278+
if len(array) > 1 {
279+
return nil, fmt.Errorf("dn cannot have multiple values: %v", array)
280+
}
281+
dn, ok := array[0].(string)
282+
if !ok {
283+
return nil, fmt.Errorf("dn must be a string: %v", array[0])
284+
}
285+
e.DN = NewDN(dn)
286+
continue
287+
}
288+
289+
for _, aval := range array {
290+
switch v := aval.(type) {
291+
case string:
292+
attrVal = append(attrVal, v)
293+
case float64:
294+
attrVal = append(attrVal, strconv.FormatFloat(v, 'f', -1, 64))
295+
case bool:
296+
attrVal = append(attrVal, strconv.FormatBool(v))
297+
default:
298+
return nil, fmt.Errorf("invalid type for attribute: %v: %#T", aval, aval)
299+
}
300+
}
301+
e.Attrs[attr] = attrVal
302+
}
303+
304+
return &e, nil
305+
}

0 commit comments

Comments
 (0)