개발
로그기록 API로 넘겨 디비에 저장하기 (Audit Log, Laravel API)
by
로그기록 API로 넘겨 디비에 저장하기 (Audit Log, Laravel API)
목차
지난 글에서 Auditd를 이용해서 FTP나 SSH 접근으로 파일을 수정하는 것을 감시하고 이를 기록하는 기능을 만들어보았다.
하지만 이런 기록을 확인하려면 터미널 화면에서 일일히 조회해야하는데, 이 과정이 여긴 귀찮은게 아니다.
제일 베스트는 이런 기록을 ERP등의 웹사이트에서 확인하는 것이 최고다.
이를 수행하기위해서 몇가지 전략이 필요한데 아래와 같은 과정을 통해 접근할 수 있다.

1. 송신단 작업 (php+curl)
일단 나는 php 환경이기 때문에 최대한 이 환경을 이용해서 작업해보려한다.
이 작업을 하기위해 미리 체크해줘야 하는 점은 콘솔상에서 php런이 되는 환경이라는점과 curl을 통해 데이터를 쏴 줄 수 있는지다.
일단 로그 수집과 필터링, 송신을 위한 php 코드를 만들어보자.
1-1. 송신 php 코드
<?php
// ======================
// 설정
// ======================
$logDir = '/var/log/audit';
$filesToRead = [
$logDir . '/audit.log.1', // 로테이션 직후 누락 방지용 (있으면 읽음)
$logDir . '/audit.log', // 현재 로그
];
$stateFile = '/var/lib/audit-exporter/state.json';
$endpoint = 'http://your.domain.com/api/audit/upload';
$secretToken = 'YOUR_TOKEN';
$server = 'builder';
$keyPrefix = 'builder_';
$maxEvents = 10000; // 1회 전송 상한(안전장치, path 기준 row 수)
// ======================
// 상태 로드/저장
// ======================
function ensure_dir($path) {
if (!is_dir($path)) {
mkdir($path, 0755, true);
}
}
function load_state($stateFile) {
if (!file_exists($stateFile)) return ['last_serial' => 0, 'last_epoch' => 0];
$json = file_get_contents($stateFile);
$data = json_decode($json, true);
if (!is_array($data)) return ['last_serial' => 0, 'last_epoch' => 0];
return [
'last_serial' => (int)($data['last_serial'] ?? 0),
'last_epoch' => (int)($data['last_epoch'] ?? 0)
];
}
function save_state($stateFile, $lastSerial, $lastEpoch) {
file_put_contents($stateFile, json_encode([
'last_serial' => (int)$lastSerial,
'last_epoch' => (int)$lastEpoch,
'saved_at' => date('c'),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
// ======================
// 파서 유틸
// ======================
function project_from_key($key, $prefix = 'builder_') {
if (strncmp($key, $prefix, strlen($prefix)) !== 0) return 0;
$tail = substr($key, strlen($prefix));
if (preg_match('/^(\d+)/', $tail, $m)) return (int)$m[1];
return $tail; // 숫자가 아니면 문자열 그대로 반환
}
// ======================
// 메인
// ======================
ensure_dir(dirname($stateFile));
$state = load_state($stateFile);
$lastSerial = (int)$state['last_serial'];
$lastEpoch = (int)$state['last_epoch'];
$events = []; // serial => event_data
$maxSerialSeen = $lastSerial;
$maxEpochSeen = $lastEpoch;
foreach ($filesToRead as $file) {
if (!file_exists($file)) continue;
$fp = fopen($file, 'rb');
if (!$fp) continue;
while (($line = fgets($fp)) !== false) {
// msg=audit(1771976737.891:6848)
if (!preg_match('/msg=audit\(\d+\.\d+:(\d+)\)/', $line, $m)) {
continue;
}
$serial = (int)$m[1];
// epoch는 별도로 추출
$epoch = 0;
if (preg_match('/msg=audit\((\d+)\.\d+:/', $line, $me)) {
$epoch = (int)$me[1];
}
// Epoch + Serial 기반 필터링 (시간이 지났거나, 같은 시간인데 시리얼이 컸을 때만 새로운 로그)
if ($epoch < $lastEpoch) {
continue;
}
if ($epoch === $lastEpoch && $serial <= $lastSerial) {
continue;
}
if (!isset($events[$serial])) {
$events[$serial] = [
'server' => $server,
'project' => null,
'uid' => null,
'auid' => null,
'syscall' => null,
'cwd' => null,
'serial' => $serial,
'epoch' => $epoch,
'paths' => [],
'has_key' => false,
];
}
// key 추출 및 타겟 확인
if (preg_match('/\bkey="([^"]+)"/', $line, $mk) || preg_match('/\bkey=([^\s]+)/', $line, $mk)) {
$key = $mk[1];
if (strncmp($key, $keyPrefix, strlen($keyPrefix)) === 0) {
$events[$serial]['has_key'] = true;
$events[$serial]['project'] = project_from_key($key, $keyPrefix);
}
}
// UID/AUID 추출
if (preg_match('/\buid=(\d+)/', $line, $mu)) $events[$serial]['uid'] = (int)$mu[1];
if (preg_match('/\bauid=(\d+)/', $line, $ma)) $events[$serial]['auid'] = (int)$ma[1];
// Syscall 추출
if (preg_match('/\bsyscall=(\d+)/', $line, $ms)) $events[$serial]['syscall'] = $ms[1];
// CWD 추출
if (preg_match('/\bcwd="([^"]+)"/', $line, $mc) || preg_match('/\bcwd=([^\s]+)/', $line, $mc)) {
$events[$serial]['cwd'] = $mc[1];
}
// Path 추출
if (preg_match('/\bname="([^"]+)"/', $line, $mp)) {
$path = $mp[1];
if (!in_array($path, $events[$serial]['paths'])) {
$events[$serial]['paths'][] = $path;
}
}
if ($epoch > $maxEpochSeen) {
$maxEpochSeen = $epoch;
$maxSerialSeen = $serial;
} elseif ($epoch === $maxEpochSeen && $serial > $maxSerialSeen) {
$maxSerialSeen = $serial;
}
}
fclose($fp);
}
// ======================
// payload 구성
// ======================
$payload = [];
foreach ($events as $ev) {
if (!$ev['has_key']) continue;
if (empty($ev['paths'])) continue;
foreach ($ev['paths'] as $path) {
// 절대 경로 변환 (CWD 결합)
$absPath = $path;
if (substr($path, 0, 1) !== '/' && !empty($ev['cwd'])) {
$cwd = rtrim($ev['cwd'], '/');
$absPath = $cwd . '/' . ltrim($path, './');
}
$payload[] = [
'server' => $ev['server'],
'project' => $ev['project'],
'uid' => $ev['uid'],
'auid' => $ev['auid'],
'syscall' => $ev['syscall'],
'path' => $absPath,
'serial' => $ev['serial'],
'epoch' => $ev['epoch'],
];
if (count($payload) >= $maxEvents) {
break 2;
}
}
}
if (count($payload) === 0) {
echo "No new builder_* events found (last_epoch={$lastEpoch}, last_serial={$lastSerial}).\n";
if ($maxEpochSeen > $lastEpoch || ($maxEpochSeen === $lastEpoch && $maxSerialSeen > $lastSerial)) {
// 읽은 로그 중 더 최신 지점이 있다면 업데이트
save_state($stateFile, $maxSerialSeen, $maxEpochSeen);
echo "State updated to max seen: {$maxEpochSeen}:{$maxSerialSeen}\n";
}
exit(0);
}
// ======================
// 전송
// ======================
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $secretToken
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
echo "Sent " . count($payload) . " rows. HTTP={$httpCode}\n";
if ($err) echo "cURL error: $err\n";
echo "Response: " . ($response ?? '') . "\n";
// ======================
// 상태 저장
// ======================
if ($httpCode >= 200 && $httpCode < 300) {
// 전송 성공 시, 이번에 확인한 가장 최신 지점(Epoch+Serial)으로 업데이트
save_state($stateFile, $maxSerialSeen, $maxEpochSeen);
echo "State updated: last_epoch={$maxEpochSeen}, last_serial={$maxSerialSeen}\n";
} else {
echo "State NOT updated (will retry next run).\n";
exit(1);
}
Copy코드는 생각보다 긴데 차근차근 하나씩 구조를 짚어보면 아래와 같다.
1) 작업 경로 및 변수 정의
2) 몇번째 시퀸스까지 작업했는지 체크/기록하기위한 재사용 함수 및 파싱용 함수 정의
3)
/var/lib/audit-exporter/state.json 에 저장된 시퀸스 값 로드4) 로그 파일을 열어
msg=audit 문자열로 시작하는 로그 라인을 기준으로 파싱5) epoch 값과 serial값을 기반으로 기록 데이터 위치로 이동
6) 추출키로부터 대상 필터
6) 추출키로부터 대상 필터
7) 데이터 추출(uid, auid, syscall, cwd, name 등)
8) 추출한 데이터를 기준으로
payload 배열 생성9) 지정된 엔드포인트로
payload를 json으로 변환 후 POST 전송토큰의 경우는 보안을 위해 임의로 만든 값이기 때문에 이런게 필요없는 환경이라면
Authorization: Bearer 과정을 제외해도된다. 하지만 하는 걸 추천한다.1-2. 접근 파일
이 작업을 할때 결국 건드리는 파일은 다음과 같은데,
- 이 파일이 실행되는
send_log_audit.php- 실행 결과를 담을
send_log_audit.log- 조회할 로그파일인
audit.log 과 audit.log.1- 시퀸스 값을 저장해둘
state.json이렇게 4개이다.
세번째 로그파일을 제외한 나머지는 어차피 외부에서 접근을 할 필요없는 파일이기때문에 chmod 777로 세팅해둬도 상관없는데, 문제는 로그파일이다.

해당 로그파일이 auditd로 생성되는 파일인데, 이프로세스가 root에서 돌다보니까 위에처럼 파일의 소유권이 잡히게 된다.
이 소유권을 함부로 바꿀수도 없는 노릇이고, 자동생성되는 파일이다보니까 직접 권한을 변경하는것도 한계가 있다.
이때문에 해당 파일을 '읽을' 수 있는 권한만 충족되면 되는 상황에서 해당 파일의 권한그룹이
adm인걸 이용하기로 한다.나중에 자동화를 돌릴 유저로 접속하고 콘솔창에 id 를 치면 아래와 같이 권한 그룹이 나오게되는데,

이 권한그룹에 저 로그 파일의 그룹이 있는지 확인한다.
없으면 아래 명령어로 추가해주면 된다.
# 현재 로그인한 유저에게 adm 권한그룹 추가
sudo usermod -aG adm $(whoami)
# 확인 방법
id -nGCopy이렇게 해두면 송신측 php 를 실행할 때 권한문제가 사라지게된다.
그리고 송신 결과를 기록할 로그파일을 만들어주면 끝.
touch /var/www/html/send_log_audit.logCopy2. 수신단 작업 (laravel)
라라벨 프레임워크에서 작업을 한다면 이 과정은 생각보다 쉽다.
그냥 api용 라우터를 열어주고, 맞는 모델과 테이블을 만들어두고, 컨트롤러에서 온 요청을 파싱해 디비에 넣으면 끝이다.
순차적으로 해보자.
2-1. api 라우터 추가
Route::post('audit/upload', 'AuditLogController@uploadAuditByAPI');Copy라우터 파일
routes/api.php 를 열어서 위 라우터를 추가해준다.나는
Authorization: Bearer 같은 보안처리도 같이 진행했고, 다른 api 구현층이 있어서 위 파일에 했지만, 그런 작업을 하지 않는다면 그냥 무난하게 web.php에 해도 상관은 없다.하지만 본 글에선 내가 작업한 기록을 남기는게 목적이기때문에 api 기준으로 설명해보겠다.
2-2. token 생성
라라벨 프로젝트에서 환경변수 파일
.env에 지정해놓은 토큰값을 설정으로 쓰기위해서는 config 폴더에 이를 정의해둘 필요가 있다.먼저 .env에 아래와 같이 임의의 토큰을 생성해두자
LOG_UPLOAD_TOKEN=YOUR_TOKENCopy임의 토큰값은 아래와 같이 php가 설치된 터미널이나 콘솔창에서 쉽게 생성할 수 있다.
php -r '$chars="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; $n=strlen($chars)-1; $t=""; for($i=0;$i<64;$i++){ $t.=$chars[random_int(0,$n)]; } echo $t, PHP_EOL;'Copy그리고 config/services.php 파일을 열고,
return [...] 배열 속 가장 아래 구문을 추가해준다. 'log_upload' => [
'token' => env('LOG_UPLOAD_TOKEN'),
],Copy그리고 이 설정을 반영하기 위해서 캐시를 다시 작성해준다.
php artisan config:cacheCopy2-3. 모델 생성
Audit 로그를 기록하기위한 모델과 데이터베이스를 만들어 주자.
php artisan make:model Models/AuditLog -mCopy(모델 위치는 각자 적절히 만들어주자)
이렇게 모델을 생성하면 청사진이
database/migrations 에 추가된다 그 파일을 열어 아래와 같이 구조를 구현해주자.class CreateAuditLogsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('server');
$table->string('project');
$table->unsignedInteger('uid')->nullable();
$table->unsignedInteger('auid')->nullable();
$table->string('syscall')->nullable();
$table->text('path')->nullable();
$table->unsignedBigInteger('serial');
$table->unsignedInteger('epoch');
$table->timestamp('occurred_at');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('audit_logs');
}
}Copy그리고 이 청사진을 바탕으로 마이그레이션을 실시해 테이블을 추가해준다.
php artsian migrateCopy그리고 모델 파일을 열고 아래같이 모델 내부를 정의해주자.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
protected $guarded = [];
public static function getSyscall() {
return [
0 => 'read',
1 => 'write',
2 => 'open',
3 => 'close',
4 => 'stat',
5 => 'fstat',
6 => 'lstat',
7 => 'poll',
8 => 'lseek',
9 => 'mmap',
10 => 'mprotect',
11 => 'munmap',
12 => 'brk',
13 => 'rt_sigaction',
14 => 'rt_sigprocmask',
15 => 'rt_sigreturn',
16 => 'ioctl',
17 => 'pread',
18 => 'pwrite',
19 => 'readv',
20 => 'writev',
21 => 'access',
22 => 'pipe',
23 => 'select',
24 => 'sched_yield',
25 => 'mremap',
26 => 'msync',
27 => 'mincore',
28 => 'madvise',
29 => 'shmget',
30 => 'shmat',
31 => 'shmctl',
32 => 'dup',
33 => 'dup2',
34 => 'pause',
35 => 'nanosleep',
36 => 'getitimer',
37 => 'alarm',
38 => 'setitimer',
39 => 'getpid',
40 => 'sendfile',
41 => 'socket',
42 => 'connect',
43 => 'accept',
44 => 'sendto',
45 => 'recvfrom',
46 => 'sendmsg',
47 => 'recvmsg',
48 => 'shutdown',
49 => 'bind',
50 => 'listen',
51 => 'getsockname',
52 => 'getpeername',
53 => 'socketpair',
54 => 'setsockopt',
55 => 'getsockopt',
56 => 'clone',
57 => 'fork',
58 => 'vfork',
59 => 'execve',
60 => 'exit',
61 => 'wait4',
62 => 'kill',
63 => 'uname',
64 => 'semget',
65 => 'semop',
66 => 'semctl',
67 => 'shmdt',
68 => 'msgget',
69 => 'msgsnd',
70 => 'msgrcv',
71 => 'msgctl',
72 => 'fcntl',
73 => 'flock',
74 => 'fsync',
75 => 'fdatasync',
76 => 'truncate',
77 => 'ftruncate',
78 => 'getdents',
79 => 'getcwd',
80 => 'chdir',
81 => 'fchdir',
82 => 'rename',
83 => 'mkdir',
84 => 'rmdir',
85 => 'creat',
86 => 'link',
87 => 'unlink',
88 => 'symlink',
89 => 'readlink',
90 => 'chmod',
91 => 'fchmod',
92 => 'chown',
93 => 'fchown',
94 => 'lchown',
95 => 'umask',
96 => 'gettimeofday',
97 => 'getrlimit',
98 => 'getrusage',
99 => 'sysinfo',
100 => 'times',
101 => 'ptrace',
102 => 'getuid',
103 => 'syslog',
104 => 'getgid',
105 => 'setuid',
106 => 'setgid',
107 => 'geteuid',
108 => 'getegid',
109 => 'setpgid',
110 => 'getppid',
111 => 'getpgrp',
112 => 'setsid',
113 => 'setreuid',
114 => 'setregid',
115 => 'getgroups',
116 => 'setgroups',
117 => 'setresuid',
118 => 'getresuid',
119 => 'setresgid',
120 => 'getresgid',
121 => 'getpgid',
122 => 'setfsuid',
123 => 'setfsgid',
124 => 'getsid',
125 => 'capget',
126 => 'capset',
127 => 'rt_sigpending',
128 => 'rt_sigtimedwait',
129 => 'rt_sigqueueinfo',
130 => 'rt_sigsuspend',
131 => 'sigaltstack',
132 => 'utime',
133 => 'mknod',
134 => 'uselib',
135 => 'personality',
136 => 'ustat',
137 => 'statfs',
138 => 'fstatfs',
139 => 'sysfs',
140 => 'getpriority',
141 => 'setpriority',
142 => 'sched_setparam',
143 => 'sched_getparam',
144 => 'sched_setscheduler',
145 => 'sched_getscheduler',
146 => 'sched_get_priority_max',
147 => 'sched_get_priority_min',
148 => 'sched_rr_get_interval',
149 => 'mlock',
150 => 'munlock',
151 => 'mlockall',
152 => 'munlockall',
153 => 'vhangup',
154 => 'modify_ldt',
155 => 'pivot_root',
156 => '_sysctl',
157 => 'prctl',
158 => 'arch_prctl',
159 => 'adjtimex',
160 => 'setrlimit',
161 => 'chroot',
162 => 'sync',
163 => 'acct',
164 => 'settimeofday',
165 => 'mount',
166 => 'umount2',
167 => 'swapon',
168 => 'swapoff',
169 => 'reboot',
170 => 'sethostname',
171 => 'setdomainname',
172 => 'iopl',
173 => 'ioperm',
174 => 'create_module',
175 => 'init_module',
176 => 'delete_module',
177 => 'get_kernel_syms',
178 => 'query_module',
179 => 'quotactl',
180 => 'nfsservctl',
181 => 'getpmsg',
182 => 'putpmsg',
183 => 'afs_syscall',
184 => 'tuxcall',
185 => 'security',
186 => 'gettid',
187 => 'readahead',
188 => 'setxattr',
189 => 'lsetxattr',
190 => 'fsetxattr',
191 => 'getxattr',
192 => 'lgetxattr',
193 => 'fgetxattr',
194 => 'listxattr',
195 => 'llistxattr',
196 => 'flistxattr',
197 => 'removexattr',
198 => 'lremovexattr',
199 => 'fremovexattr',
200 => 'tkill',
201 => 'time',
202 => 'futex',
203 => 'sched_setaffinity',
204 => 'sched_getaffinity',
205 => 'set_thread_area',
206 => 'io_setup',
207 => 'io_destroy',
208 => 'io_getevents',
209 => 'io_submit',
210 => 'io_cancel',
211 => 'get_thread_area',
212 => 'lookup_dcookie',
213 => 'epoll_create',
214 => 'epoll_ctl_old',
215 => 'epoll_wait_old',
216 => 'remap_file_pages',
217 => 'getdents64',
218 => 'set_tid_address',
219 => 'restart_syscall',
220 => 'semtimedop',
221 => 'fadvise64',
222 => 'timer_create',
223 => 'timer_settime',
224 => 'timer_gettime',
225 => 'timer_getoverrun',
226 => 'timer_delete',
227 => 'clock_settime',
228 => 'clock_gettime',
229 => 'clock_getres',
230 => 'clock_nanosleep',
231 => 'exit_group',
232 => 'epoll_wait',
233 => 'epoll_ctl',
234 => 'tgkill',
235 => 'utimes',
236 => 'vserver',
237 => 'mbind',
238 => 'set_mempolicy',
239 => 'get_mempolicy',
240 => 'mq_open',
241 => 'mq_unlink',
242 => 'mq_timedsend',
243 => 'mq_timedreceive',
244 => 'mq_notify',
245 => 'mq_getsetattr',
246 => 'kexec_load',
247 => 'waitid',
248 => 'add_key',
249 => 'request_key',
250 => 'keyctl',
251 => 'ioprio_set',
252 => 'ioprio_get',
253 => 'inotify_init',
254 => 'inotify_add_watch',
255 => 'inotify_rm_watch',
256 => 'migrate_pages',
257 => 'openat',
258 => 'mkdirat',
259 => 'mknodat',
260 => 'fchownat',
261 => 'futimesat',
262 => 'newfstatat',
263 => 'unlinkat',
264 => 'renameat',
265 => 'linkat',
266 => 'symlinkat',
267 => 'readlinkat',
268 => 'fchmodat',
269 => 'faccessat',
270 => 'pselect6',
271 => 'ppoll',
272 => 'unshare',
273 => 'set_robust_list',
274 => 'get_robust_list',
275 => 'splice',
276 => 'tee',
277 => 'sync_file_range',
278 => 'vmsplice',
279 => 'move_pages',
280 => 'utimensat',
281 => 'epoll_pwait',
282 => 'signalfd',
283 => 'timerfd',
284 => 'eventfd',
285 => 'fallocate',
286 => 'timerfd_settime',
287 => 'timerfd_gettime',
288 => 'accept4',
289 => 'signalfd4',
290 => 'eventfd2',
291 => 'epoll_create1',
292 => 'dup3',
293 => 'pipe2',
294 => 'inotify_init1',
295 => 'preadv',
296 => 'pwritev',
297 => 'rt_tgsigqueueinfo',
298 => 'perf_event_open',
299 => 'recvmmsg',
300 => 'fanotify_init',
301 => 'fanotify_mark',
302 => 'prlimit64',
303 => 'name_to_handle_at',
304 => 'open_by_handle_at',
305 => 'clock_adjtime',
306 => 'syncfs',
307 => 'sendmmsg',
308 => 'setns',
309 => 'getcpu',
310 => 'process_vm_readv',
311 => 'process_vm_writev',
312 => 'kcmp',
313 => 'finit_module',
314 => 'sched_setattr',
315 => 'sched_getattr',
316 => 'renameat2',
317 => 'seccomp',
318 => 'getrandom',
319 => 'memfd_create',
320 => 'kexec_file_load',
321 => 'bpf',
322 => 'execveat',
323 => 'userfaultfd',
324 => 'membarrier',
325 => 'mlock2',
326 => 'copy_file_range',
327 => 'preadv2',
328 => 'pwritev2',
329 => 'pkey_mprotect',
330 => 'pkey_alloc',
331 => 'pkey_free',
332 => 'statx',
];
}
public function getSyscallName()
{
return self::getSyscall()[$this->syscall] ?? 'unknown';
}
public static function getUser()
{
return [
0 => 'root',
1 => 'daemon',
2 => 'bin',
3 => 'sys',
4 => 'sync',
5 => 'games',
6 => 'man',
7 => 'lp',
8 => 'mail',
9 => 'news',
10 => 'uucp',
13 => 'proxy',
33 => 'www-data',
34 => 'backup',
38 => 'list',
39 => 'irc',
41 => 'gnats',
65534 => 'nobody',
100 => 'systemd-network',
101 => 'systemd-resolve',
102 => 'syslog',
103 => 'messagebus',
104 => '_apt',
105 => 'lxd',
106 => 'uuidd',
107 => 'dnsmasq',
108 => 'landscape',
109 => 'sshd',
110 => 'pollinate',
1000 => 'ubuntu',
1001 => 'kiweb',
];
}
public function getUserName()
{
return self::getUser()[$this->auid] ?? 'unknown';
}
}
Copy이렇게 해주면 모델과 테이블 작업은 끝.
마지막으로 컨트롤러단으로 넘어가보자.
2-4. 컨트롤러 작업
일단 라우터에서 지정한 컨트롤러명으로 새로운 컨트롤러를 만들어보자.
php artisan make:controller AuditLogControllerCopy그리고 해당 컨트롤러를 열어서
uploadAuditByAPI 메소드를 아래와 같이 구현한다.public function uploadAuditByAPI(Request $request)
{
// 인증 토큰 확인
$token = $request->bearerToken();
if ($token !== config('services.log_upload.token')) {
return response()->json(['message' => 'Unauthorized'], 401);
}
$payload = $request->all(); // JSON 구조: [ {server, project, uid, auid, syscall, path, serial, epoch, occurred_at}, ... ]
$dataToInsert = [];
foreach ($payload as $item) {
if (!is_array($item)) continue;
// 필수값 체크
if (!isset($item['server'], $item['project'], $item['serial'], $item['epoch'])) {
continue;
}
$server = (string) $item['server'];
$serial = (int) $item['serial'];
$epoch = (int) $item['epoch'];
$occurredAt = Carbon::createFromTimestamp($epoch, 'Asia/Seoul')->toDateTimeString();
$auid = isset($item['auid']) ? (int) $item['auid'] : 0;
if ($auid === 4294967295) $auid = null;
$dataToInsert[] = [
'server' => $server,
'project' => (string) $item['project'],
'uid' => isset($item['uid']) ? (int) $item['uid'] : 0,
'auid' => $auid,
'syscall' => isset($item['syscall']) && $item['syscall'] !== '' ? (string) $item['syscall'] : null,
'path' => isset($item['path']) && $item['path'] !== '' ? (string) $item['path'] : null,
'serial' => $serial,
'epoch' => $epoch,
'occurred_at' => $occurredAt,
'created_at' => now(),
'updated_at' => now(),
];
}
if (!empty($dataToInsert)) {
// server와 serial 조합으로 기존 데이터 존재 여부 확인
$serials = array_column($dataToInsert, 'serial');
$servers = array_unique(array_column($dataToInsert, 'server'));
$existing = DB::table('audit_logs')
->whereIn('server', $servers)
->whereIn('serial', $serials)
->get(['server', 'serial'])
->map(function($row) {
return $row->server . '_' . $row->serial;
})
->toArray();
$finalData = array_filter($dataToInsert, function($item) use ($existing) {
return !in_array($item['server'] . '_' . $item['serial'], $existing);
});
if (!empty($finalData)) {
DB::table('audit_logs')->insert($finalData);
}
}
return response()->json(['message' => 'OK']);
}Copy이 컨트롤러의 역할은 지극히 심플하다.
1) 제대로된 토큰을 가진 요청인지 검토
2)
payload 요청값을 가져오기3) 배열을 순차적으로 순회하며 데이터 추출
4) 이미 있는 데이터는 스킵하며 없는 데이터는 디비에 기록
데이터 기록이 완료되면 OK 메시지를 반환하고 종료다.
이렇게 기록되는 데이터는 아래와 같이 디비에 저장된다.
6073,builder,demo,1001,1001,257,/var/www/demo/resources/views/,9210,1771998309,2026-02-25 14:45:09,2026-02-25 14:58:03,2026-02-25 14:58:03
6074,builder,demo,1001,1001,257,/var/www/demo/resources/views/test.php,9210,1771998309,2026-02-25 14:45:09,2026-02-25 14:58:03,2026-02-25 14:58:03
6075,builder,8185,1001,1001,83,/var/www/8185/resources/views/app/,9211,1771998682,2026-02-25 14:51:22,2026-02-25 14:58:03,2026-02-25 14:58:03
6076,builder,8185,1001,1001,257,/var/www/8185/resources/views/app/contents/,9212,1771998682,2026-02-25 14:51:22,2026-02-25 14:58:03,2026-02-25 14:58:03
6077,builder,8185,1001,1001,257,/var/www/8185/resources/views/app/contents/about_institution.blade.php,9212,1771998682,2026-02-25 14:51:22,2026-02-25 14:58:03,2026-02-25 14:58:03
CopyCSV 출력 예시
3. 테스트
이렇게 송신단과 수신단 구현을 해놓고 테스트를 해보자.
3-1. 수신단 테스트
일단 송신단 서버로 가서 아래와 같은 파일을 하나 만들어주자.
touch /tmp/audit_test.json
nano /tmp/audit_test.jsonCopy이렇게 파일을 만들고 에디트 창을 열어 아래와 같이 파일 구성하고 저장한다.
[
{
"server": "builder",
"project": 8100,
"uid": 1001,
"auid": 1001,
"syscall": "257",
"path": "/var/www/demo/resources/views/test.php",
"serial": 8503,
"epoch": 1771984873
},
{
"server": "builder",
"project": 8100,
"uid": 1001,
"auid": 1001,
"syscall": "257",
"path": "/var/www/demo/resources/views/index.php",
"serial": 8504,
"epoch": 1771984874
},
{
"server": "builder",
"project": "demo_project",
"uid": 1001,
"auid": 1001,
"syscall": "257",
"path": "/home/kiweb/builder_demo/config.php",
"serial": 8505,
"epoch": 1771984875
}
]Copy그리고 직접 curl 명령어를 통해 아래와 같이 데이터를 날려보자.
curl -i -X POST "http://your.domain.com/api/audit/upload" -H "Content-Type: application/json" -H "Authorization: Bearer YOUR_TOKEN" --data-binary "@/tmp/audit_test.json"Copy이렇게 송신했을 경우 수신단에서 제대로 테이블에 데이터가 기록이 된다면 수신단은 문제 없다는 것이다.
3-2. 송신단 테스트
송신단은 php 스크립트로 저 위에서 만들어놓은 php 코드를 실행시킨다.
/usr/bin/php /var/www/html/send_log_audit.php >> /var/www/html/send_log_audit.log 2>&1Copy이렇게 실행 시켰을때 별 문제가없이 실행되면 코드상 문제는 없다는 것,
이 타이밍에 제일 많이 발생하는게 권한문제다.
이럴경우엔 해당 파일의 권한이나 유저의 권한그룹을 다시 확인해보자.
제대로 실행됐다면 아래 명령어로 실행결과를 확인할 수 있다.
cat /var/www/html/send_log_audit.log
# 이런 기록을 확인할 수 있다
No new builder_* events found (last_serial=8211).
Sent 6077 rows. HTTP=200
Response: {"message":"OK"}
State updated: last_epoch=1771998682, last_serial=9213Copy그리고 마지막으로 처리한 기록이 설정 json 파일에 아래와 같이 기록되는것도 확인할 수 있다.
cat /var/lib/audit-exporter/state.json
# 내부 기록
{
"last_serial": 9213,
"last_epoch": 1771998682,
"saved_at": "2026-02-25T14:58:04+09:00"
}Copy충분히 테스트를 했고 라이브로 적용하려면 위 설정파일을 열어서 시리얼 값과 epoch 값을 0으로 바꿔주면 처음부터 다시 송/수신 작업을 진행한다.
4. 자동화
이렇게 복잡하게 구현한 이유는 최종적으로 자동화에 올리기 위함이다.
리눅스에서 제일 쉽게 이용할 수 있는 자동화는
crontab 이다.아래 명령어를 통해 크론탭 설정파일을 열고 명령어를 설정해주자
crontab -e
# 가장 아래에
30 * * * * /usr/bin/php /var/www/html/send_log_audit.php >> /var/www/html/send_log_audit.log 2>&1Copy이렇게 설정해주면 1시간마다(12:30, 1:30, 2:30...) Audit 로그를 읽고 이를 API를 이용해 디비에 기록하는것이 가능하다.
이렇게 까지가 로그파일을 테이블에 기록하기위한 발버둥이라고 보면 될 것 같다!
어휴 힘들어~
#Ubuntu #Nginx #Laravel
0
개의 댓글