legion-tty 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE +201 -0
- data/README.md +138 -0
- data/exe/legion-tty +17 -0
- data/lib/legion/tty/app.rb +277 -0
- data/lib/legion/tty/background/github_probe.rb +357 -0
- data/lib/legion/tty/background/kerberos_probe.rb +205 -0
- data/lib/legion/tty/background/scanner.rb +160 -0
- data/lib/legion/tty/boot_logger.rb +34 -0
- data/lib/legion/tty/components/digital_rain.rb +138 -0
- data/lib/legion/tty/components/input_bar.rb +46 -0
- data/lib/legion/tty/components/markdown_view.rb +17 -0
- data/lib/legion/tty/components/message_stream.rb +86 -0
- data/lib/legion/tty/components/status_bar.rb +89 -0
- data/lib/legion/tty/components/token_tracker.rb +46 -0
- data/lib/legion/tty/components/tool_panel.rb +93 -0
- data/lib/legion/tty/components/wizard_prompt.rb +49 -0
- data/lib/legion/tty/hotkeys.rb +29 -0
- data/lib/legion/tty/screen_manager.rb +63 -0
- data/lib/legion/tty/screens/base.rb +28 -0
- data/lib/legion/tty/screens/chat.rb +428 -0
- data/lib/legion/tty/screens/dashboard.rb +211 -0
- data/lib/legion/tty/screens/onboarding.rb +463 -0
- data/lib/legion/tty/session_store.rb +74 -0
- data/lib/legion/tty/theme.rb +63 -0
- data/lib/legion/tty/version.rb +7 -0
- data/lib/legion/tty.rb +30 -0
- metadata +247 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module TTY
|
|
9
|
+
module Background
|
|
10
|
+
# rubocop:disable Metrics/ClassLength
|
|
11
|
+
class GitHubProbe
|
|
12
|
+
API_BASE = 'https://api.github.com'
|
|
13
|
+
USER_AGENT = 'legion-tty/github-probe'
|
|
14
|
+
|
|
15
|
+
def initialize(token: nil, logger: nil)
|
|
16
|
+
@log = logger
|
|
17
|
+
@token = token || resolve_token
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# rubocop:disable Metrics/AbcSize
|
|
21
|
+
def run_quick_async(queue)
|
|
22
|
+
Thread.new do
|
|
23
|
+
unless @token
|
|
24
|
+
@log&.log('github', 'quick probe skipped (no token)')
|
|
25
|
+
queue.push({ type: :github_quick_complete, data: nil })
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@log&.log('github', 'quick probe: fetching /user + recent commits')
|
|
30
|
+
t0 = Time.now
|
|
31
|
+
result = fetch_quick_profile
|
|
32
|
+
elapsed = ((Time.now - t0) * 1000).round
|
|
33
|
+
@log&.log('github', "quick probe complete in #{elapsed}ms")
|
|
34
|
+
queue.push({ type: :github_quick_complete, data: result })
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
@log&.log('github', "quick probe ERROR: #{e.class}: #{e.message}")
|
|
37
|
+
queue.push({ type: :github_quick_error, error: e.message })
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
# rubocop:enable Metrics/AbcSize
|
|
41
|
+
|
|
42
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
43
|
+
def run_async(queue, remotes: [], quick_profile: nil)
|
|
44
|
+
Thread.new do
|
|
45
|
+
@log&.log('github', "probing with #{remotes.size} remotes: #{remotes.first(5).inspect}")
|
|
46
|
+
@log&.log('github', "token: #{@token ? 'present' : 'NONE'}")
|
|
47
|
+
@log&.log('github', "quick_profile: #{quick_profile ? 'reusing' : 'none'}")
|
|
48
|
+
t0 = Time.now
|
|
49
|
+
result = if @token
|
|
50
|
+
build_authenticated_result(remotes, quick_profile: quick_profile)
|
|
51
|
+
else
|
|
52
|
+
build_public_result(remotes)
|
|
53
|
+
end
|
|
54
|
+
elapsed = ((Time.now - t0) * 1000).round
|
|
55
|
+
@log&.log('github', "probe complete in #{elapsed}ms")
|
|
56
|
+
queue.push({ type: :github_probe_complete, data: result })
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
@log&.log('github', "ERROR: #{e.class}: #{e.message}")
|
|
59
|
+
queue.push({ type: :github_error, error: e.message })
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# --- Quick profile: just /user + commit count (runs during rain) ---
|
|
67
|
+
|
|
68
|
+
# rubocop:disable Metrics/AbcSize
|
|
69
|
+
def fetch_quick_profile
|
|
70
|
+
user_data = api_get('/user')
|
|
71
|
+
return nil unless user_data.is_a?(Hash) && user_data['login']
|
|
72
|
+
|
|
73
|
+
username = user_data['login']
|
|
74
|
+
@log&.log('github', "quick: authenticated as #{username}")
|
|
75
|
+
|
|
76
|
+
week_ago = (Time.now - (7 * 86_400)).strftime('%Y-%m-%d')
|
|
77
|
+
month_ago = (Time.now - (30 * 86_400)).strftime('%Y-%m-%d')
|
|
78
|
+
|
|
79
|
+
commits_week = count_commits(username, since: week_ago)
|
|
80
|
+
commits_month = count_commits(username, since: month_ago)
|
|
81
|
+
@log&.log('github', "quick: commits this week=#{commits_week} this month=#{commits_month}")
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
username: username,
|
|
85
|
+
name: user_data['name'],
|
|
86
|
+
public_repos: user_data['public_repos'],
|
|
87
|
+
private_repos: user_data['total_private_repos'],
|
|
88
|
+
followers: user_data['followers'],
|
|
89
|
+
created_at: user_data['created_at'],
|
|
90
|
+
commits_this_week: commits_week,
|
|
91
|
+
commits_this_month: commits_month
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
# rubocop:enable Metrics/AbcSize
|
|
95
|
+
|
|
96
|
+
def count_commits(username, since:)
|
|
97
|
+
query = URI.encode_www_form_component("author:#{username} committer-date:>#{since}")
|
|
98
|
+
uri = URI("#{API_BASE}/search/commits?q=#{query}&per_page=1")
|
|
99
|
+
http = build_http(uri)
|
|
100
|
+
request = build_request(uri)
|
|
101
|
+
request['Accept'] = 'application/vnd.github.cloak-preview+json'
|
|
102
|
+
response = http.request(request)
|
|
103
|
+
data = ::JSON.parse(response.body)
|
|
104
|
+
return 0 unless data.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
data['total_count'] || 0
|
|
107
|
+
rescue StandardError
|
|
108
|
+
0
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# --- Authenticated path: GET /user tells us who we are ---
|
|
112
|
+
|
|
113
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
114
|
+
def build_authenticated_result(remotes, quick_profile: nil)
|
|
115
|
+
if quick_profile
|
|
116
|
+
username = quick_profile[:username]
|
|
117
|
+
@log&.log('github', "reusing quick profile for: #{username}")
|
|
118
|
+
profile = quick_profile
|
|
119
|
+
else
|
|
120
|
+
user_data = api_get('/user')
|
|
121
|
+
unless user_data.is_a?(Hash) && user_data['login']
|
|
122
|
+
@log&.log('github', 'authenticated /user failed, falling back to public')
|
|
123
|
+
return build_public_result(remotes)
|
|
124
|
+
end
|
|
125
|
+
username = user_data['login']
|
|
126
|
+
@log&.log('github', "authenticated as: #{username}")
|
|
127
|
+
profile = extract_profile(user_data)
|
|
128
|
+
end
|
|
129
|
+
orgs = fetch_orgs
|
|
130
|
+
private_repos = fetch_private_repos
|
|
131
|
+
public_repos = fetch_public_repos(username)
|
|
132
|
+
recent_prs = fetch_recent_prs(username)
|
|
133
|
+
events = fetch_recent_events(username)
|
|
134
|
+
notifications = fetch_notifications
|
|
135
|
+
starred = fetch_starred
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
username: username,
|
|
139
|
+
authenticated: true,
|
|
140
|
+
profile: profile,
|
|
141
|
+
orgs: orgs,
|
|
142
|
+
private_repos: private_repos,
|
|
143
|
+
public_repos: public_repos,
|
|
144
|
+
recent_prs: recent_prs,
|
|
145
|
+
events: events,
|
|
146
|
+
notifications: notifications,
|
|
147
|
+
starred: starred
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
151
|
+
|
|
152
|
+
def extract_profile(data)
|
|
153
|
+
{
|
|
154
|
+
login: data['login'],
|
|
155
|
+
name: data['name'],
|
|
156
|
+
bio: data['bio'],
|
|
157
|
+
public_repos: data['public_repos'],
|
|
158
|
+
private_repos: data['total_private_repos'],
|
|
159
|
+
company: data['company'],
|
|
160
|
+
location: data['location'],
|
|
161
|
+
email: data['email'],
|
|
162
|
+
created_at: data['created_at'],
|
|
163
|
+
followers: data['followers'],
|
|
164
|
+
following: data['following']
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def fetch_orgs
|
|
169
|
+
data = api_get('/user/orgs')
|
|
170
|
+
return [] unless data.is_a?(Array)
|
|
171
|
+
|
|
172
|
+
data.map { |o| { login: o['login'], description: o['description'] } }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def fetch_private_repos(limit: 10)
|
|
176
|
+
data = api_get("/user/repos?visibility=private&sort=updated&per_page=#{limit}")
|
|
177
|
+
return [] unless data.is_a?(Array)
|
|
178
|
+
|
|
179
|
+
data.map { |r| extract_repo(r) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# rubocop:disable Metrics/AbcSize
|
|
183
|
+
def fetch_recent_prs(username, limit: 10)
|
|
184
|
+
query = URI.encode_www_form_component("author:#{username} type:pr sort:updated")
|
|
185
|
+
data = api_get("/search/issues?q=#{query}&per_page=#{limit}")
|
|
186
|
+
return [] unless data.is_a?(Hash) && data['items'].is_a?(Array)
|
|
187
|
+
|
|
188
|
+
data['items'].map do |pr|
|
|
189
|
+
repo_url = pr['repository_url']
|
|
190
|
+
repo_name = repo_url ? repo_url.split('/').last(2).join('/') : nil
|
|
191
|
+
{
|
|
192
|
+
title: pr['title'],
|
|
193
|
+
repo: repo_name,
|
|
194
|
+
state: pr['state'],
|
|
195
|
+
created_at: pr['created_at'],
|
|
196
|
+
updated_at: pr['updated_at'],
|
|
197
|
+
url: pr['html_url']
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
# rubocop:enable Metrics/AbcSize
|
|
202
|
+
|
|
203
|
+
def fetch_notifications(limit: 10)
|
|
204
|
+
data = api_get("/notifications?per_page=#{limit}")
|
|
205
|
+
return [] unless data.is_a?(Array)
|
|
206
|
+
|
|
207
|
+
data.map do |n|
|
|
208
|
+
{
|
|
209
|
+
reason: n['reason'],
|
|
210
|
+
title: n.dig('subject', 'title'),
|
|
211
|
+
type: n.dig('subject', 'type'),
|
|
212
|
+
repo: n.dig('repository', 'full_name'),
|
|
213
|
+
updated_at: n['updated_at']
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def fetch_starred(limit: 10)
|
|
219
|
+
data = api_get("/user/starred?per_page=#{limit}&sort=created&direction=desc")
|
|
220
|
+
return [] unless data.is_a?(Array)
|
|
221
|
+
|
|
222
|
+
data.map { |r| extract_repo(r) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# --- Public path: infer username from remotes ---
|
|
226
|
+
|
|
227
|
+
def build_public_result(remotes)
|
|
228
|
+
username = remotes.filter_map { |r| infer_username(r) }.first
|
|
229
|
+
@log&.log('github', "inferred username: #{username || 'none'}")
|
|
230
|
+
return { username: nil } unless username
|
|
231
|
+
|
|
232
|
+
profile_data = api_get("/users/#{username}")
|
|
233
|
+
profile = profile_data.is_a?(Hash) ? extract_profile(profile_data) : nil
|
|
234
|
+
|
|
235
|
+
{
|
|
236
|
+
username: username,
|
|
237
|
+
authenticated: false,
|
|
238
|
+
profile: profile,
|
|
239
|
+
public_repos: fetch_public_repos(username),
|
|
240
|
+
events: fetch_recent_events(username)
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def infer_username(remote_url)
|
|
245
|
+
return nil if remote_url.nil? || remote_url.empty?
|
|
246
|
+
|
|
247
|
+
match = remote_url.match(%r{github\.com[:/]([^/]+)/})
|
|
248
|
+
match ? match[1] : nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# --- Shared fetchers ---
|
|
252
|
+
|
|
253
|
+
def fetch_public_repos(username, limit: 10)
|
|
254
|
+
data = api_get("/users/#{username}/repos?sort=updated&per_page=#{limit}")
|
|
255
|
+
return [] unless data.is_a?(Array)
|
|
256
|
+
|
|
257
|
+
data.map { |r| extract_repo(r) }
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def fetch_recent_events(username, limit: 10)
|
|
261
|
+
data = api_get("/users/#{username}/events/public?per_page=#{limit}")
|
|
262
|
+
return [] unless data.is_a?(Array)
|
|
263
|
+
|
|
264
|
+
data.first(limit).map do |e|
|
|
265
|
+
{
|
|
266
|
+
type: e['type'],
|
|
267
|
+
repo: e.dig('repo', 'name'),
|
|
268
|
+
created_at: e['created_at']
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def extract_repo(repo)
|
|
274
|
+
{
|
|
275
|
+
full_name: repo['full_name'],
|
|
276
|
+
language: repo['language'],
|
|
277
|
+
private: repo['private'],
|
|
278
|
+
updated_at: repo['updated_at'],
|
|
279
|
+
description: repo['description']
|
|
280
|
+
}
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# --- Token resolution ---
|
|
284
|
+
|
|
285
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
286
|
+
def resolve_token
|
|
287
|
+
env_token = ENV.fetch('GITHUB_TOKEN', nil) ||
|
|
288
|
+
ENV.fetch('GH_TOKEN', nil) ||
|
|
289
|
+
ENV.fetch('GITHUB_PERSONAL_ACCESS_TOKEN', nil)
|
|
290
|
+
if env_token
|
|
291
|
+
@log&.log('github', 'token source: environment variable')
|
|
292
|
+
return env_token
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
gh_token = token_from_gh_cli
|
|
296
|
+
if gh_token
|
|
297
|
+
@log&.log('github', 'token source: gh CLI')
|
|
298
|
+
return gh_token
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
@log&.log('github', 'no token found (no env var, no gh CLI)')
|
|
302
|
+
nil
|
|
303
|
+
end
|
|
304
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
305
|
+
|
|
306
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
307
|
+
def token_from_gh_cli
|
|
308
|
+
gh_path = `which gh 2>/dev/null`.strip
|
|
309
|
+
return nil if gh_path.empty?
|
|
310
|
+
|
|
311
|
+
@log&.log('github', "found gh CLI at #{gh_path}")
|
|
312
|
+
|
|
313
|
+
status = `gh auth status 2>&1`
|
|
314
|
+
@log&.log('github', "gh auth status: #{status.lines.first&.strip}")
|
|
315
|
+
return nil unless $CHILD_STATUS&.success?
|
|
316
|
+
|
|
317
|
+
token = `gh auth token 2>/dev/null`.strip
|
|
318
|
+
return nil if token.empty?
|
|
319
|
+
|
|
320
|
+
token
|
|
321
|
+
rescue StandardError => e
|
|
322
|
+
@log&.log('github', "gh CLI error: #{e.message}")
|
|
323
|
+
nil
|
|
324
|
+
end
|
|
325
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
326
|
+
|
|
327
|
+
# --- HTTP ---
|
|
328
|
+
|
|
329
|
+
def api_get(path)
|
|
330
|
+
uri = URI("#{API_BASE}#{path}")
|
|
331
|
+
http = build_http(uri)
|
|
332
|
+
response = http.request(build_request(uri))
|
|
333
|
+
::JSON.parse(response.body)
|
|
334
|
+
rescue StandardError
|
|
335
|
+
nil
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def build_http(uri)
|
|
339
|
+
http = ::Net::HTTP.new(uri.host, uri.port)
|
|
340
|
+
http.use_ssl = true
|
|
341
|
+
http.open_timeout = 3
|
|
342
|
+
http.read_timeout = 5
|
|
343
|
+
http
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def build_request(uri)
|
|
347
|
+
request = ::Net::HTTP::Get.new(uri)
|
|
348
|
+
request['User-Agent'] = USER_AGENT
|
|
349
|
+
request['Accept'] = 'application/vnd.github+json'
|
|
350
|
+
request['Authorization'] = "Bearer #{@token}" if @token
|
|
351
|
+
request
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
# rubocop:enable Metrics/ClassLength
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'resolv'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module TTY
|
|
8
|
+
module Background
|
|
9
|
+
# rubocop:disable Metrics/ClassLength
|
|
10
|
+
class KerberosProbe
|
|
11
|
+
def initialize(logger: nil)
|
|
12
|
+
@log = logger
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def probe
|
|
16
|
+
principal = read_principal
|
|
17
|
+
return nil unless principal
|
|
18
|
+
|
|
19
|
+
username = principal.split('@', 2).first
|
|
20
|
+
realm = principal.include?('@') ? principal.split('@', 2).last : nil
|
|
21
|
+
profile = ldap_profile(username, realm)
|
|
22
|
+
|
|
23
|
+
build_result(principal: principal, username: username, realm: realm, profile: profile)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run_async(queue)
|
|
27
|
+
Thread.new do
|
|
28
|
+
@log&.log('kerberos', 'running klist...')
|
|
29
|
+
t0 = Time.now
|
|
30
|
+
result = probe
|
|
31
|
+
elapsed = ((Time.now - t0) * 1000).round
|
|
32
|
+
log_result(result, elapsed)
|
|
33
|
+
queue.push(type: :kerberos_complete, data: result)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
@log&.log('kerberos', "ERROR: #{e.class}: #{e.message}")
|
|
36
|
+
queue.push(type: :kerberos_error, error: e.message)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def read_principal
|
|
43
|
+
output = `klist 2>/dev/null`
|
|
44
|
+
return nil unless $CHILD_STATUS&.success?
|
|
45
|
+
|
|
46
|
+
match = output.match(/Principal:\s+(\S+)/i)
|
|
47
|
+
match&.[](1)&.strip
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ldap_profile(username, realm)
|
|
51
|
+
@log&.log('kerberos', "ldap_profile called: username=#{username} realm=#{realm.inspect}")
|
|
52
|
+
return {} unless realm
|
|
53
|
+
|
|
54
|
+
dc_host = discover_dc(realm)
|
|
55
|
+
unless dc_host
|
|
56
|
+
@log&.log('kerberos', "no domain controller found for #{realm}")
|
|
57
|
+
return {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
base_dn = realm_to_base_dn(realm)
|
|
61
|
+
@log&.log('kerberos', "LDAP lookup: #{username} via #{dc_host} base=#{base_dn}")
|
|
62
|
+
query_ldap(username: username, host: dc_host, base_dn: base_dn)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
66
|
+
def discover_dc(realm)
|
|
67
|
+
domain = realm.downcase
|
|
68
|
+
srv_name = "_ldap._tcp.#{domain}"
|
|
69
|
+
records = Resolv::DNS.open { |dns| dns.getresources(srv_name, Resolv::DNS::Resource::IN::SRV) }
|
|
70
|
+
host = records.min_by(&:priority)&.target&.to_s
|
|
71
|
+
@log&.log('kerberos', "SRV #{srv_name} -> #{host || 'none'} (#{records.size} records)")
|
|
72
|
+
host
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
@log&.log('kerberos', "SRV lookup failed: #{e.message}")
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
78
|
+
|
|
79
|
+
def realm_to_base_dn(realm)
|
|
80
|
+
realm.downcase.split('.').map { |part| "DC=#{part}" }.join(',')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def query_ldap(username:, host:, base_dn:)
|
|
84
|
+
output = run_ldapsearch(username: username, host: host, base_dn: base_dn)
|
|
85
|
+
return {} unless output
|
|
86
|
+
|
|
87
|
+
profile = parse_ldif(output)
|
|
88
|
+
@log&.log('kerberos', "LDAP profile: #{profile.inspect}")
|
|
89
|
+
profile
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
@log&.log('kerberos', "LDAP error: #{e.class}: #{e.message}")
|
|
92
|
+
{}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def run_ldapsearch(username:, host:, base_dn:)
|
|
96
|
+
cmd = "ldapsearch -H ldap://#{host} -b #{base_dn.shellescape} " \
|
|
97
|
+
"\"(sAMAccountName=#{username.shellescape})\" " \
|
|
98
|
+
'givenName sn mail displayName title department company ' \
|
|
99
|
+
'l st co whenCreated 2>/dev/null'
|
|
100
|
+
output = `#{cmd}`
|
|
101
|
+
return nil unless $CHILD_STATUS&.success?
|
|
102
|
+
|
|
103
|
+
output
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
107
|
+
def parse_ldif(output)
|
|
108
|
+
attrs = {}
|
|
109
|
+
output.each_line do |line|
|
|
110
|
+
line = line.chomp
|
|
111
|
+
case line
|
|
112
|
+
when /\AgivenName:\s*(.+)/ then attrs[:first_name] = Regexp.last_match(1).strip
|
|
113
|
+
when /\Asn:\s*(.+)/ then attrs[:last_name] = Regexp.last_match(1).strip
|
|
114
|
+
when /\Amail:\s*(.+)/ then attrs[:email] = Regexp.last_match(1).strip
|
|
115
|
+
when /\AdisplayName:\s*(.+)/ then attrs[:display_name] = Regexp.last_match(1).strip
|
|
116
|
+
when /\Atitle:\s*(.+)/ then attrs[:title] = Regexp.last_match(1).strip
|
|
117
|
+
when /\Adepartment:\s*(.+)/ then attrs[:department] = Regexp.last_match(1).strip
|
|
118
|
+
when /\Acompany:\s*(.+)/ then attrs[:company] = Regexp.last_match(1).strip
|
|
119
|
+
when /\Al:\s*(.+)/ then attrs[:city] = Regexp.last_match(1).strip
|
|
120
|
+
when /\Ast:\s*(.+)/ then attrs[:state] = Regexp.last_match(1).strip
|
|
121
|
+
when /\Aco:\s*(.+)/ then attrs[:country] = Regexp.last_match(1).strip
|
|
122
|
+
when /\AwhenCreated:\s*(.+)/ then attrs[:when_created] = Regexp.last_match(1).strip
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
attrs
|
|
126
|
+
end
|
|
127
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
128
|
+
|
|
129
|
+
def build_result(principal:, username:, realm:, profile:)
|
|
130
|
+
first = profile[:first_name] || username.capitalize
|
|
131
|
+
last = profile[:last_name]
|
|
132
|
+
display = profile[:display_name] || [first, last].compact.join(' ')
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
principal: principal,
|
|
136
|
+
username: username,
|
|
137
|
+
realm: realm,
|
|
138
|
+
first_name: first,
|
|
139
|
+
last_name: last,
|
|
140
|
+
email: profile[:email],
|
|
141
|
+
display_name: display,
|
|
142
|
+
title: profile[:title],
|
|
143
|
+
department: profile[:department],
|
|
144
|
+
company: profile[:company],
|
|
145
|
+
city: profile[:city],
|
|
146
|
+
state: profile[:state],
|
|
147
|
+
country: profile[:country],
|
|
148
|
+
tenure_years: calculate_tenure(profile[:when_created])
|
|
149
|
+
}.compact
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
153
|
+
def calculate_tenure(when_created)
|
|
154
|
+
return nil unless when_created&.length&.>=(8)
|
|
155
|
+
|
|
156
|
+
# AD generalized time: 20170505044456.0Z
|
|
157
|
+
start_year = when_created[0, 4].to_i
|
|
158
|
+
start_month = when_created[4, 2].to_i
|
|
159
|
+
start_day = when_created[6, 2].to_i
|
|
160
|
+
return nil if start_year.zero?
|
|
161
|
+
|
|
162
|
+
now = Time.now
|
|
163
|
+
days = now.day - start_day
|
|
164
|
+
months = now.month - start_month
|
|
165
|
+
years = now.year - start_year
|
|
166
|
+
|
|
167
|
+
if days.negative?
|
|
168
|
+
months -= 1
|
|
169
|
+
prev_month = now.month - 1
|
|
170
|
+
prev_year = now.year
|
|
171
|
+
if prev_month.zero?
|
|
172
|
+
prev_month = 12
|
|
173
|
+
prev_year -= 1
|
|
174
|
+
end
|
|
175
|
+
days += days_in_month(prev_month, prev_year)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
if months.negative?
|
|
179
|
+
years -= 1
|
|
180
|
+
months += 12
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
return nil if years.negative?
|
|
184
|
+
|
|
185
|
+
{ years: years, months: months, days: days }
|
|
186
|
+
end
|
|
187
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
188
|
+
|
|
189
|
+
def days_in_month(month, year)
|
|
190
|
+
Time.new(year, month, -1).day
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def log_result(result, elapsed)
|
|
194
|
+
if result
|
|
195
|
+
@log&.log('kerberos', "found principal: #{result[:principal]} (#{elapsed}ms)")
|
|
196
|
+
@log&.log('kerberos', "name=#{result[:display_name]} email=#{result[:email]}")
|
|
197
|
+
else
|
|
198
|
+
@log&.log('kerberos', "no kerberos ticket found (#{elapsed}ms)")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
# rubocop:enable Metrics/ClassLength
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|