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)," ",[]),',')),'/')) defp qdn0(rdn), do: binary(:string.join(rev(tok(string(rdn),'/')),',')) 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, 'and') <> ")" defp match({:or, list}), do: "(" <> join(list, '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: :erlang.spawn(fn -> listen(:application.get_env(:ldap,:port,1489), :application.get_env(:ldap,:instance,code())) end) # Create table and tune SQL settings defp initDB(path) do {:ok, conn} = open(path) :logger.info 'SYNRC LDAP Instance: ~p', [path] :logger.info 'SYNRC LDAP Connection: ~p', [conn] 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, "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 '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(db, statement, [hash(qdn(bindDN)),password]) case step(db, statement) do :done -> code = :invalidCredentials :logger.error 'BIND Error: ~p', [code] response = LDAP.DS."BindResponse"(resultCode: code, matchedDN: "", diagnosticMessage: 'ERROR') answer(response, no, :bindResponse, socket) {:row,[dn,password]} -> :logger.info 'BIND DN: ~p', [bindDN] response = LDAP.DS."BindResponse"(resultCode: :success, matchedDN: "", diagnosticMessage: 'OK') answer(response, no, :bindResponse, socket) end end def message(no, socket, {:bindRequest, {_,_,bindDN,creds}}, db) do code = :authMethodNotSupported :logger.info 'BIND ERROR: ~p', [code] response = LDAP.DS."BindResponse"(resultCode: code, matchedDN: "", diagnosticMessage: 'ERROR') answer(response, no, :bindResponse, socket) end def message(no, socket, {:searchRequest, {_,"",scope,_,limit,_,_,filter,attributes}}, db) do :logger.info 'DSE Scope: ~p', [scope] :logger.info 'DSE Filter: ~p', [filter] :logger.info 'DSE Attr: ~p', [attributes] :lists.map(fn response -> answer(response,no,:searchResEntry,socket) end, [ node("", [ attr("supportedLDAPVersion", ['3']), attr("namingContexts", ['dc=synrc,dc=com','ou=schema']), attr("supportedControl", ['1.3.6.1.4.1.4203.1.10.1']), attr("supportedFeatures", ['1.3.6.1.1.14', '1.3.6.1.4.1.4203.1.5.1']), attr("supportedExtensions", ['1.3.6.1.4.1.4203.1.11.3']), attr("altServer", ['ldap.synrc.com']), attr("subschemaSubentry", ['ou=schema']), attr("vendorName", ['SYNRC LDAP']), attr("vendorVersion", ['13.7.24']), attr("supportedSASLMechanisms", ['SIMPLE']), attr("objectClass", ['top','extensibleObject']), attr("entryUUID", [code()])])]) resp = LDAP.DS.'LDAPResult'(resultCode: :success, matchedDN: "", diagnosticMessage: 'OK') answer(resp, no, :searchResDone,socket) end def message(no, socket, {:searchRequest, {_,bindDN,scope,_,limit,_,_,filter,attributes}}, db) do :logger.info 'SEARCH DN: ~p', [qdn(bindDN)] :logger.info 'SEARCH Scope: ~p', [scope] :logger.info 'SEARCH Filter: ~p', [filter] :logger.info 'SEARCH Attr: ~p', [attributes] search(socket, no, db, filter, scope, qdn(bindDN)) resp = LDAP.DS.'LDAPResult'(resultCode: :success, matchedDN: "", diagnosticMessage: 'OK') answer(resp, no, :searchResDone, socket) end def message(no, socket, {:modDNRequest, {_,dn,new,del,_}}, db) do :logger.info 'MOD RDN DN: ~p', [dn] :logger.info 'MOD RDN newRDN: ~p', [new] :logger.info 'MOD RDN deleteOldRDN: ~p', [del] modifyRDN(socket, no, db, dn, new, del) resp = LDAP.DS.'LDAPResult'(resultCode: :success, matchedDN: dn, diagnosticMessage: '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(db, statement, [hash(qdn(dn))]) case step(db, statement) do {:row, _} -> :logger.info 'MOD DN: ~p', [dn] modifyDN(db, dn, attributes) resp = LDAP.DS.'LDAPResult'(resultCode: :success, matchedDN: dn, diagnosticMessage: 'OK') answer(resp, no, :modifyResponse, socket) :done -> :logger.info 'MOD ERROR: ~p', [dn] resp = LDAP.DS.'LDAPResult'(resultCode: :noSuchObject, matchedDN: dn, diagnosticMessage: 'ERROR') answer(resp, no, :modifyResponse, socket) end end def message(no, socket, {:compareRequest, {_,dn, assertion}}, db) do :logger.info 'CMP DN: ~p', [dn] :logger.info 'CMP Assertion: ~p', [assertion] result = compareDN(db, dn, assertion) response = LDAP.DS.'LDAPResult'(resultCode: result, matchedDN: dn, diagnosticMessage: '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(db, statement, [hash(qdn(dn))]) case step(db, statement) do {:row, _} -> :logger.info 'ADD ERROR: ~p', [dn] resp = LDAP.DS.'LDAPResult'(resultCode: :entryAlreadyExists, matchedDN: dn, diagnosticMessage: 'ERROR') answer(resp, no, :addResponse, socket) :done -> createDN(db, dn, attributes) :logger.info 'ADD DN: ~p', [dn] resp = LDAP.DS.'LDAPResult'(resultCode: :success, matchedDN: dn, diagnosticMessage: 'OK') answer(resp, no, :addResponse, socket) end end def message(no, socket, {:delRequest, dn}, db) do :logger.info 'DEL DN: ~p', [dn] deleteDN(db, dn) resp = LDAP.DS.'LDAPResult'(resultCode: :success, matchedDN: dn, diagnosticMessage: 'OK') answer(resp, no, :delResponse, socket) end def message(no, socket, {:extendedReq,{_,oid,val}} = msg, db) do :logger.info 'EXT: ~p', [msg] res = LDAP.DS.'ExtendedResponse'(resultCode: :unavailable, diagnosticMessage: '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 '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 [] -> [] ; _ -> ',' 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 'MODIFY RDN UPDATE: ~p', [hash(qdn(dn))] bind(db, 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 'MOD ADD RDN: ~p', [hash(qdn(dn))] bind(db, 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 'MOD REPLACE RDN: ~p', [hash(qdn(dn))] bind(db, 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 'MOD DEL RDN: ~p', [hash(qdn(dn))] bind(db, 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(db, 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('(?~p,?~p,?~p)',[acc+1,acc+2,acc+3])} end, {0,[]}, norm) {:ok, statement} = prepare(db, 'insert into ldap (rdn,att,val) values ' ++ p ++ '') :ok = bind(db, statement, :lists.flatten(norm)) :done = step(db, statement) end end