defmodule LDAP do @moduledoc """ The LDAPv3 server implementation module. """ import Exqlite.Sqlite3 require LDAP.DS use Application use Supervisor def init([]), do: {:ok, { {:one_for_one, 5, 10}, []} } def start(_type, _args) do :logger.add_handlers(:ldap) LDAP.start :supervisor.start_link({:local, LDAP}, LDAP, []) end # Prelude defp hash(x), do: x defp string(rdn), do: :erlang.binary_to_list(rdn) defp binary(rdn), do: :erlang.iolist_to_binary(rdn) defp tok(rdn, del), do: :string.tokens(rdn, del) defp rev(list), do: :lists.reverse(list) defp code(), do: :binary.encode_hex(:crypto.strong_rand_bytes(8)) defp replace(s,a,b), do: :re.replace(s,a,b,[:global,{:return,:list}]) defp qdn(rdn), do: binary(:string.join(rev(tok(replace(string(rdn)," ",[]),~c",")),~c"/")) defp qdn0(rdn), do: binary(:string.join(rev(tok(string(rdn),~c"/")),~c",")) defp bin(x) when is_integer(x), do: :erlang.integer_to_binary(x) defp bin(x) when is_list(x), do: :erlang.iolist_to_binary(x) defp bin(x), do: x defp collect0(_db,_st,:done, acc), do: acc defp collect0(db,st,{:row,x}, acc), do: collect0(db,st,step(db,st),[:erlang.list_to_tuple(x)|acc]) # Prelude @doc "The `PartialAttribute` ASN.1 constructor." def attr(k,v), do: {:PartialAttribute, k, v} @doc "The `SearchResultEntry` ASN.1 constructor." def node(dn,attrs), do: {:SearchResultEntry, dn, attrs} # Collect results from Search Query defp collect(socket,no,_db,_st,:done, dn, att, values, attributes, nodes) do answer(node(qdn0(dn),[attr(att,values)|attributes]),no,:searchResEntry,socket) [node(dn,[attr(att,values)|attributes])|nodes] end defp collect(socket,no,db,st,{:row,[xrdn,xatt,yval]}, dn, att, values, attributes, nodes) do xval = bin(yval) next = step(db,st) case xrdn == dn do false -> answer(node(qdn0(dn),[attr(att,values)|attributes]),no,:searchResEntry,socket) collect(socket,no,db,st,next,xrdn,xatt,[xval],[],[node(dn,[attr(att,values)|attributes])|nodes]) true -> case xatt == att do true -> collect(socket,no,db,st,next,dn,xatt,[xval|values],attributes,nodes) false -> collect(socket,no,db,st,next,dn,xatt,[xval],[attr(att,values)|attributes],nodes) end end end # SQL query defp query(:baseObject,_q,dn), do: "select * from ldap where rdn = '#{dn}'" defp query(:singleLevel,q,dn), do: "select * from ldap where rdn in (select rdn from ldap where (rdn like '#{dn}/%') and " <> match(q) <> ")" defp query(:wholeSubtree,q,dn), do: "select * from ldap where rdn in (select rdn from ldap where (rdn like '#{dn}%') and " <> match(q) <> ")" defp join(list, op), do: :string.join(:lists.map(fn x -> :erlang.binary_to_list("(" <> match(x) <> ")") end, list), op) |> :erlang.iolist_to_binary defp match({:equalityMatch, {_, a, v}}), do: "(att = '#{a}' and val = '#{v}')" defp match({:substrings, {_, a, [final: v]}}), do: "(att = '#{a}' and val like '#{v}%')" defp match({:substrings, {_, a, [initial: v]}}), do: "(att = '#{a}' and val like '%#{v}')" defp match({:substrings, {_, a, [any: v]}}), do: "(att = '#{a}' and val like '%#{v}%')" defp match({:present, a}), do: "(att = '#{a}')" defp match({:and, list}), do: "(" <> join(list, ~c"and") <> ")" defp match({:or, list}), do: "(" <> join(list, ~c"or") <> ")" defp match({:not, x}), do: "(not(" <> match(x) <> "))" # Search defp search(socket, no, db, filter, scope, dn) do {:ok, st} = prepare(db, query(scope, filter, dn)) case step(db,st) do :done -> [] {:row, [dn,att,val]} -> collect(socket,no,db,st,{:row,[dn,att,val]},dn,att,[],[],[]) end end # Dump database @doc "Dump database." def list(name) do {:ok, db} = open(name) {:ok, st} = prepare(db, "select * from ldap") res = step(db,st) collect0(db,st,res,[]) end # Start @doc "Start server base on `:port` and `:instance` application environment parameters." def start() do :logger.info ~c"Instance: ~p", [code()] :erlang.spawn(fn -> listen(:application.get_env(:ldap,:port,1489), :application.get_env(:ldap,:instance,code())) end) end # Create table and tune SQL settings defp initDB(path) do {:ok, conn} = open(path) execute(conn, "create table ldap (rdn text,att text,val binary)") :ok = execute(conn, "PRAGMA journal_mode = OFF;") :ok = execute(conn, "PRAGMA temp_store = MEMORY;") :ok = execute(conn, "PRAGMA cache_size = 1000000;") :ok = execute(conn, "PRAGMA synchronous = 0;") conn end # TCP listen defp listen(port,path) do conn = initDB(path) createDN(conn, "dc=synrc,dc=com", [attr("dc",["synrc"]),attr("objectClass",["top","domain"])]) createDN(conn, "dc=mil,dc=gov", [attr("dc",["mil"]),attr("objectClass",["top","domain"])]) createDN(conn, "cn=Vampire,dc=mil,dc=gov", [attr("cn",["Vampire"]),attr("uid",["1"]),attr("objectClass",[])]) createDN(conn, "cn=PD2,dc=mil,dc=gov", [attr("cn",["PD2"]),attr("uid",["2"]),attr("objectClass",[])]) createDN(conn, "ou=schema", [attr("ou",["schema"]),attr("objectClass",["top","domain"])]) createDN(conn, "cn=tonpa,dc=synrc,dc=com", [attr("cn",["tonpa"]),attr("uid",["1000"]),attr("objectClass",["inetOrgPerson","posixAccount"])]) createDN(conn, "cn=rocco,dc=synrc,dc=com", [attr("cn",["rocco"]),attr("uid",["1001"]),attr("objectClass",["inetOrgPerson","posixAccount"])]) createDN(conn, "cn=admin,dc=synrc,dc=com", [attr("rootpw",["secret"]),attr("cn",["admin"]),attr("objectClass",["inetOrgPerson"])]) {:ok, socket} = :gen_tcp.listen(port, [:binary, {:packet, 0}, {:active, false}, {:reuseaddr, true}]) accept(socket,conn) end # TCP accept defp accept(socket,conn) do {:ok, fd} = :gen_tcp.accept(socket) mod = :application.get_env(:ldap,:module,LDAP) :erlang.spawn(fn -> loop(fd, conn, mod) end) accept(socket,conn) end # TCP looper defp loop(socket, db, mod) do case :gen_tcp.recv(socket, 0) do {:ok, data} -> case :"LDAP".decode(:"LDAPMessage",data) do {:ok,decoded} -> {:"LDAPMessage", no, payload, _} = decoded mod.message(no, socket, payload, db) loop(socket, db, mod) {:error,reason} -> :logger.error ~c"ERROR: ~p", [reason] :exit end {:error, :closed} -> :exit end end # LDAP protocol messagae handler @doc "The public LDAPv3 protocol handler function." def message(no, socket, {:bindRequest, {_,_,bindDN,{:simple, password}}}, db) do {:ok, statement} = prepare(db, "select rdn, att from ldap where rdn = ?1 and att = 'rootpw' and val = ?2") bind(statement, [hash(qdn(bindDN)),password]) case step(db, statement) do :done -> code = :invalidCredentials :logger.error ~c"BIND Error: ~p", [code] response = LDAP.DS."BindResponse"(resultCode: code, matchedDN: "", diagnosticMessage: ~c"ERROR") answer(response, no, :bindResponse, socket) {:row,[_dn,_password]} -> :logger.info ~c"BIND DN: ~p", [bindDN] response = LDAP.DS."BindResponse"(resultCode: :success, matchedDN: "", diagnosticMessage: ~c"OK") answer(response, no, :bindResponse, socket) end end def message(no, socket, {:bindRequest, {_,_,_bindDN,_creds}}, _db) do code = :authMethodNotSupported :logger.info ~c"BIND ERROR: ~p", [code] response = LDAP.DS."BindResponse"(resultCode: code, matchedDN: "", diagnosticMessage: ~c"ERROR") answer(response, no, :bindResponse, socket) end def message(no, socket, {:searchRequest, {_,"",scope,_,_limit,_,_,filter,attributes}}, _db) do :logger.info ~c"DSE Scope: ~p", [scope] :logger.info ~c"DSE Filter: ~p", [filter] :logger.info ~c"DSE Attr: ~p", [attributes] :lists.map(fn response -> answer(response,no,:searchResEntry,socket) end, [ node("", [ attr("supportedLDAPVersion", [~c"3"]), attr("namingContexts", [~c"dc=synrc,dc=com",~c"ou=schema",~c"dc=mil,dc=gov"]), attr("supportedControl", [~c"1.3.6.1.4.1.4203.1.10.1"]), attr("supportedFeatures", [~c"1.3.6.1.1.14", ~c"1.3.6.1.4.1.4203.1.5.1"]), attr("supportedExtensions", [~c"1.3.6.1.4.1.4203.1.11.3"]), attr("altServer", [~c"ldap.synrc.com"]), attr("subschemaSubentry", [~c"ou=schema"]), attr("vendorName", [~c"SYNRC LDAP"]), attr("vendorVersion", [~c"13.7.24"]), attr("supportedSASLMechanisms", [~c"SIMPLE"]), attr("objectClass", [~c"top",~c"extensibleObject"]), attr("entryUUID", [code()])])]) resp = LDAP.DS."LDAPResult"(resultCode: :success, matchedDN: "", diagnosticMessage: ~c"OK") answer(resp, no, :searchResDone,socket) end def message(no, socket, {:searchRequest, {_,bindDN,scope,_,_limit,_,_,filter,attributes}}, db) do :logger.info ~c"SEARCH DN: ~p", [qdn(bindDN)] :logger.info ~c"SEARCH Scope: ~p", [scope] :logger.info ~c"SEARCH Filter: ~p", [filter] :logger.info ~c"SEARCH Attr: ~p", [attributes] search(socket, no, db, filter, scope, qdn(bindDN)) resp = LDAP.DS."LDAPResult"(resultCode: :success, matchedDN: "", diagnosticMessage: ~c"OK") answer(resp, no, :searchResDone, socket) end def message(no, socket, {:modDNRequest, {_,dn,new,del,_}}, db) do :logger.info ~c"MOD RDN DN: ~p", [dn] :logger.info ~c"MOD RDN newRDN: ~p", [new] :logger.info ~c"MOD RDN deleteOldRDN: ~p", [del] modifyRDN(socket, no, db, dn, new, del) resp = LDAP.DS."LDAPResult"(resultCode: :success, matchedDN: dn, diagnosticMessage: ~c"OK") answer(resp, no, :modDNResponse, socket) end def message(no, socket, {:modifyRequest, {_,dn, attributes}}, db) do {:ok, statement} = prepare(db, "select rdn, att, val from ldap where rdn = ?1") bind(statement, [hash(qdn(dn))]) case step(db, statement) do {:row, _} -> :logger.info ~c"MOD DN: ~p", [dn] modifyDN(db, dn, attributes) resp = LDAP.DS."LDAPResult"(resultCode: :success, matchedDN: dn, diagnosticMessage: ~c"OK") answer(resp, no, :modifyResponse, socket) :done -> :logger.info ~c"MOD ERROR: ~p", [dn] resp = LDAP.DS."LDAPResult"(resultCode: :noSuchObject, matchedDN: dn, diagnosticMessage: ~c"ERROR") answer(resp, no, :modifyResponse, socket) end end def message(no, socket, {:compareRequest, {_,dn, assertion}}, db) do :logger.info ~c"CMP DN: ~p", [dn] :logger.info ~c"CMP Assertion: ~p", [assertion] result = compareDN(db, dn, assertion) response = LDAP.DS."LDAPResult"(resultCode: result, matchedDN: dn, diagnosticMessage: ~c"OK") answer(response, no, :compareResponse, socket) end def message(no, socket, {:addRequest, {_,dn, attributes}}, db) do {:ok, statement} = prepare(db, "select rdn, att, val from ldap where rdn = ?1") bind(statement, [hash(qdn(dn))]) case step(db, statement) do {:row, _} -> :logger.info ~c"ADD ERROR: ~p", [dn] resp = LDAP.DS."LDAPResult"(resultCode: :entryAlreadyExists, matchedDN: dn, diagnosticMessage: ~c"ERROR") answer(resp, no, :addResponse, socket) :done -> createDN(db, dn, attributes) :logger.info ~c"ADD DN: ~p", [dn] resp = LDAP.DS."LDAPResult"(resultCode: :success, matchedDN: dn, diagnosticMessage: ~c"OK") answer(resp, no, :addResponse, socket) end end def message(no, socket, {:delRequest, dn}, db) do :logger.info ~c"DEL DN: ~p", [dn] deleteDN(db, dn) resp = LDAP.DS."LDAPResult"(resultCode: :success, matchedDN: dn, diagnosticMessage: ~c"OK") answer(resp, no, :delResponse, socket) end def message(no, socket, {:extendedReq,{_,oid,val}} = msg, _db) do :logger.info ~c"EXT: ~p", [msg] res = LDAP.DS."ExtendedResponse"(resultCode: :unavailable, diagnosticMessage: ~c"ERROR", responseName: oid, responseValue: val) answer(res, no, :extendedResp, socket) end def message(_no, socket, {:abandonRequest, _}, _db), do: :gen_tcp.close(socket) def message(_no, socket, {:unbindRequest, _}, _db), do: :gen_tcp.close(socket) def message(_no, socket, msg, _sql) do :logger.info ~c"Invalid LDAP Message: ~p", [msg] :gen_tcp.close(socket) end # LDAP protocol message answer defp answer(response, no, op, socket) do message = LDAP.DS."LDAPMessage"(messageID: no, protocolOp: {op, response}) {:ok, bytes} = :"LDAP".encode(:"LDAPMessage", message) _send = :gen_tcp.send(socket, :erlang.iolist_to_binary(bytes)) end defp appendNotEmpty([]), do: [] defp appendNotEmpty(res) do res ++ case res do [] -> [] ; _ -> ~c"," end end defp modifyRDN(_socket, _no, db, dn, new, _del) do {:ok, st} = prepare(db, "update ldap set rdn = ?1 where rdn = ?2") :logger.info ~c"MODIFY RDN UPDATE: ~p", [hash(qdn(dn))] bind(st, [new,hash(qdn(dn))]) step(db,st) end defp modifyDN(db, dn, attributes), do: :lists.map(fn {_, :add, x} -> modifyAdd(db,dn,x) {_, :replace, x} -> modifyReplace(db,dn,x) {_, :delete, x} -> modifyDelete(db,dn,x) end, attributes) defp modifyAdd(db, dn, {_,att,[val]}) do {:ok, st} = prepare(db, "insert into ldap (rdn,att,val) values (?1,?2,?3)") :logger.info ~c"MOD ADD RDN: ~p", [hash(qdn(dn))] bind(st, [hash(qdn(dn)),att,val]) step(db,st) end defp modifyReplace(db, dn, {_,att,[val]}) do {:ok, st} = prepare(db, "update ldap set val = ?1 where rdn = ?2 and att = ?3") :logger.info ~c"MOD REPLACE RDN: ~p", [hash(qdn(dn))] bind(st, [val,hash(qdn(dn)),att]) step(db,st) end defp modifyDelete(db, dn, {_,att,_}) do {:ok, st} = prepare(db, "delete from ldap where rdn = ?1 and att = ?2") :logger.info ~c"MOD DEL RDN: ~p", [hash(qdn(dn))] bind(st, [hash(qdn(dn)),att]) res = step(db,st) collect0(db,st,res,[]) end defp deleteDN(db, dn) do {:ok, st} = prepare(db, "delete from ldap where rdn = ?1") bind(st, [hash(qdn(dn))]) res = step(db,st) collect0(db,st,res,[]) end defp compareDN(db, dn, assertion) do {:AttributeValueAssertion, a, v} = assertion {:ok, st} = prepare(db, "select * from ldap where (rdn = '#{hash(qdn(dn))}') " <> "and (att = '#{a}') and (val = '#{v}')") case step(db,st) do :done -> :compareFalse {:row,[_dn,_a,_v]} -> :compareTrue end end defp createDN(db, dn, attributes) do norm = :lists.foldr(fn {:PartialAttribute, att, vals}, acc -> :lists.map(fn val -> [qdn(dn),att,val] end, vals) ++ acc end, [], attributes) {_,p} = :lists.foldr(fn x, {acc,res} -> {acc + length(x), appendNotEmpty(res) ++ :io_lib.format(~c"(?~p,?~p,?~p)",[acc+1,acc+2,acc+3])} end, {0,[]}, norm) {:ok, statement} = prepare(db, ~c"insert into ldap (rdn,att,val) values " ++ p ++ ~c"") :ok = bind(statement, :lists.flatten(norm)) :done = step(db, statement) end end