설정 파일이 없는 FTP 서버, one-ftpserver

설정 파일 없이 명령행 옵션만으로 실행하는 FTP 서버 one-ftpserver를 소개합니다. 단일 바이너리 실행, --ssl 옵션 하나로 되는 FTPS, 접속 기록 로그, 자동 종료, JSON 출력을 정리하고 Java로 만들었던 첫 버전을 Go로 다시 쓰면서 바뀐 부분을 설명합니다.

배경

서버 작업을 하다 보면 PC에 있는 파일을 서버에 올리거나, 서버에 있는 로그 파일을 PC로 받고 싶을 때가 있습니다. 파일 하나를 옮기는 정도라면 다운로드는 Winstone으로, 업로드는 간단한 웹 애플리케이션인 uploader로 했습니다. Python이 설치된 서버라면 python3 -m http.server로 웹 서버를 띄우는 방법도 있습니다.

여러 개의 파일을 주고받을 때는 FTP가 편합니다. 그런데 대부분의 FTP 서버는 설정 파일을 만들고 사용자 목록을 따로 적어야 실행됩니다. 패키지를 내려받고, 압축을 풀고, 설정 파일을 편집하는 과정을 여러 번 반복하다 보니 실행할 때 옵션만 주면 바로 뜨는 서버가 필요했습니다. 그래서 2012년에 Apache FtpServer를 감싼 실행 가능한 jar 파일을 만들어서 one-ftpserver라는 이름으로 공개했습니다.

2026년 8월에는 같은 프로그램을 Go로 다시 작성해서 2.0.0을 냈습니다. 2.0.0부터는 JVM 없이 실행 파일 하나로 배포하고, jar 안에 넣어 두었던 FTPS용 key store도 없앴습니다. 이 글은 2026년 8월에 나온 2.2.0을 기준으로 씁니다.

  • 설정 파일을 읽지도, 쓰지도 않습니다. 모든 설정은 명령행 옵션이고, --help가 전체 목록입니다.

  • 바이너리 파일 하나로 실행됩니다. Linux와 Windows용으로 amd64, arm64 바이너리가 제공됩니다.

  • FTPS에 key store가 필요 없습니다. --ssl만 주면 인증서를 실행할 때 메모리에서 만듭니다.

  • 사용자 데이터베이스가 없습니다. 사용자는 --id, --password로 지정하는 한 명뿐입니다.

  • 클라이언트가 올린 파일과 접속 기록 로그 외에는 아무것도 남기지 않습니다.

설치와 실행

릴리스 페이지에서 플랫폼에 맞는 바이너리를 받습니다.

curl -LO https://github.com/benelog/one-ftpserver/releases/latest/download/one-ftpserver-linux-amd64
mv one-ftpserver-linux-amd64 one-ftpserver
chmod +x one-ftpserver

배포하는 파일은 다음의 4개입니다.

  • one-ftpserver-linux-amd64

  • one-ftpserver-linux-arm64

  • one-ftpserver-windows-amd64.exe

  • one-ftpserver-windows-arm64.exe

macOS에서는 go build로 직접 빌드하셔서 사용하실 수 있습니다.

아무 옵션 없이 실행하면 현재 디렉터리를 2121번 포트로 공유하고 익명 사용자(anonymous) 로그인을 받습니다.

one-ftpserver

포트, 아이디, 비밀번호, 공유할 디렉터리는 명령행에서 바로 지정합니다.

one-ftpserver --port=10021 --id=benelog --password=1234 --home=/srv/files

서버가 실행되면 적용된 설정과 함께, 그 설정에 맞는 클라이언트 명령을 계정 정보까지 채워서 출력합니다.

FTP server started : ftp://192.168.0.10:10021

# Settings
- ssl : false
- port : 10021
- passivePorts : [none]
- id : benelog
- password : 1234
- home : /srv/files
- timeout : [none]
- log : /home/benelog/one-ftpserver-2026-08-16.log

# Client commands
- upload : curl -T [filename] ftp://192.168.0.10:10021/ -u benelog:1234
- download : curl -O ftp://192.168.0.10:10021/[filename] -u benelog:1234
- download : wget --user=benelog --password=1234 ftp://192.168.0.10:10021/[filename]

이 명령을 그대로 복사해서 다른 PC에서 실행하면 파일을 주고받을 수 있습니다.

명령행 옵션

옵션 기본값 설명

--port

2121

제어 포트. 0을 주면 OS가 고른 포트를 잡고, 그 번호를 출력합니다.

--home

.

공유할 디렉터리. 클라이언트는 이 디렉터리 위로 올라갈 수 없습니다.

--id

anonymous

로그인할 수 있는 유일한 사용자.

--password

그 사용자의 비밀번호. --id만 주면 실행할 때 생성해서 출력합니다.

--passivePorts

passive 전송에 쓸 포트 범위. 10125-10199 형식으로 지정하고, 비워 두면 OS가 고릅니다.

--ssl

false

실행할 때 만든 인증서로 TLS 위에서 서비스합니다.

--cert

직접 준비한 인증서 PEM 파일. 지정하면 --ssl이 켜집니다.

--key

그 인증서의 개인 키 PEM 파일.

--timeout

0

30m처럼 지정한 시간이 지나면 서버가 스스로 종료합니다. 0이면 계속 떠 있습니다.

--publicHost

클라이언트에 알려 줄 주소. 비워 두면 서버가 찾아냅니다.

--json

false

설정 요약을 텍스트 대신 JSON으로 출력합니다.

--log

one-ftpserver.log

접속 기록을 남길 파일. 날짜별로 한 개씩 만들고, off를 주면 남기지 않습니다.

--console

false

접속 기록을 콘솔에도 출력합니다.

FTPS

one-ftpserver --ssl

--ssl을 주면 implicit FTPS로 동작합니다. ftps:// URL로 사용하는 방식입니다. 인증서는 서버가 시작할 때 메모리에서 만듭니다. 유효기간은 1년이고 localhost와 그 장비의 IP 주소를 담으며, 서버를 다시 띄우면 새로 만듭니다. 아무도 서명하지 않은 인증서라서 클라이언트가 검증할 방법이 없고, 그래서 서버가 출력하는 curl 명령에는 -k가 붙습니다.

curl -O ftps://192.168.0.10:2121/report.pdf -k

이 인증서는 전송 구간을 암호화할 뿐, 서버가 누구인지는 증명하지 못합니다. 서로 아는 두 사람이 신뢰할 수 없는 네트워크를 거쳐 파일을 주고받는 상황에는 충분하지만, 모르는 사람이 접속하는 서버라면 직접 발급받은 인증서를 PEM 파일 한 쌍으로 넘기는 편이 낫습니다. 한 쌍을 넘기면 --ssl은 저절로 켜지고, 출력되는 명령에서 -k가 빠집니다.

one-ftpserver --cert=fullchain.pem --key=privkey.pem

인증서를 무료로 발급받는 방법으로는 Let’s Encrypt가 편합니다. 도메인이 가리키는 장비에서 80번 포트를 열어 두고 발급받습니다.

sudo certbot certonly --standalone -d ftp.example.com

one-ftpserver --cert=/etc/letsencrypt/live/ftp.example.com/fullchain.pem \
              --key=/etc/letsencrypt/live/ftp.example.com/privkey.pem

직접 발급받은 인증서를 쓸 때 걸리기 쉬운 부분이 세 가지 있습니다.

  • 클라이언트는 접속에 쓴 이름을 인증서와 대조하므로, IP 주소가 아니라 발급받은 도메인으로 접속하게 해야 합니다.

  • /etc/letsencrypt 아래의 파일은 root만 읽을 수 있습니다. 서버를 root로 실행하거나, 읽을 수 있는 위치로 복사해야 합니다.

  • 인증서 파일은 시작할 때 한 번만 읽습니다. Let’s Encrypt 인증서는 90일마다 갱신되므로 갱신 후에는 서버를 다시 띄워야 합니다.

접속 기록 로그

로그인, 파일 전송, 목록 조회, 삭제, 이름 변경을 모두 기록합니다. 기본으로는 실행한 위치에 날짜가 붙은 파일을 만듭니다.

one-ftpserver-2026-08-16.log

날짜가 바뀌면 새 파일을 엽니다. 이름을 바꾸거나 지우는 처리는 하지 않으므로, 오래 띄워 둔 서버는 하루에 한 개씩 파일을 남깁니다. --log로 위치를 옮기고, off로 로그를 끕니다.

one-ftpserver --log=/var/log/ftp.log
one-ftpserver --log=off

--console은 같은 내용을 터미널에도 출력합니다. 전송이 되는지 눈으로 확인하려고 띄운 서버에 씁니다.

one-ftpserver --console
time=2026-08-16T10:27:25 level=INFO msg=login client=1 from=192.168.0.20:51000 id=benelog
time=2026-08-16T10:27:25 level=INFO msg=download client=1 from=192.168.0.20:51000 id=benelog path=/report.pdf
time=2026-08-16T10:27:26 level=INFO msg="download done" client=1 from=192.168.0.20:51000 id=benelog path=/report.pdf bytes=182004

두 옵션은 독립적입니다. --console만 주면 파일과 터미널에 함께 쓰고, --console --log=off를 주면 터미널에만 씁니다. 접속 기록은 표준 에러로 나가므로 설정 요약과 --json 출력은 표준 출력에 그대로 남습니다. 비밀번호는 로그에 남기지 않습니다. 로그인에 실패한 요청이 보낸 값도 마찬가지입니다.

정해진 시간 뒤 자동 종료

파일 하나를 옮기려고 띄운 서버가 그 뒤로도 계속 떠 있을 이유는 없습니다. --timeout에 지정한 시간이 지나면 서버가 스스로 종료합니다.

one-ftpserver --timeout=30m

NAT와 컨테이너 뒤에서 실행

passive 전송은 클라이언트가 다시 접속할 주소를 서버가 알려 주는 방식인데, 서버가 보는 주소와 클라이언트가 접근할 수 있는 주소가 다를 때가 있습니다. --publicHost로 알려 줄 주소를, --passivePorts로 방화벽에서 열거나 컨테이너에서 노출할 만큼 좁은 포트 범위를 지정합니다.

one-ftpserver --publicHost=203.0.113.10 --passivePorts=10125-10199
docker run -p 2121:2121 -p 10125-10199:10125-10199 ...

스크립트에서 쓰는 JSON 출력

--json은 텍스트 요약 대신 같은 내용을 담은 JSON 객체 하나를 출력합니다. 주소와 계정 정보를 텍스트에서 파싱하지 않고 꺼낼 수 있습니다.

one-ftpserver --id=benelog --json
{
  "address": "ftp://192.168.0.10:2121",
  "protocol": "ftp",
  "host": "192.168.0.10",
  "port": 2121,
  "id": "benelog",
  "password": "UEPH6KBIXDUIVEKC2Q23U3L4QI",
  "anonymous": false,
  "home": "/srv/files",
  "ssl": false,
  "log": "/home/benelog/one-ftpserver-2026-08-16.log",
  "upload": "curl -T [filename] ftp://192.168.0.10:2121/ -u benelog:UEPH6KBIXDUIVEKC2Q23U3L4QI",
  "download": "curl -O ftp://192.168.0.10:2121/[filename] -u benelog:UEPH6KBIXDUIVEKC2Q23U3L4QI",
  "get": "wget --user=benelog --password=UEPH6KBIXDUIVEKC2Q23U3L4QI ftp://192.168.0.10:2121/[filename]",
  "warnings": [
    "no --password was given, so this one was generated for this run"
  ]
}

--port=0과 같이 쓰면 포트를 정하지 않고 여러 대를 동시에 띄울 수 있습니다. 각 서버가 배정받은 포트를 출력합니다.

v0.1 Java 버전과 달라진 점

2.0.0은 Java로 만들었던 0.1을 Go로 다시 쓴 버전입니다. 실행 방법과 옵션 형식이 함께 바뀌었습니다.

  • 배포: java -jar one-ftpserver.jar 대신 플랫폼별 바이너리를 실행합니다. Maven 빌드는 없어졌습니다.

  • 옵션: port=10021처럼 쓰던 key=value 형식이 --port=10021 형식으로 바뀌었습니다. --ssl은 값을 받지 않습니다.

  • FTPS: 예전에는 jar 안에 넣어 둔 ftpkeystore.jks를 실행할 때마다 작업 디렉터리로 복사했고, 그 비밀번호가 소스 코드에 적혀 있었습니다. 지금은 실행할 때 메모리에서 인증서를 만들고 디스크에는 아무것도 쓰지 않습니다.

  • 비밀번호: --id만 주면 비밀번호가 아이디와 같아지던 동작을 고쳤습니다. 이제는 실행할 때마다 새로 만들어 출력합니다.

  • 홈 디렉터리: 클라이언트가 --home 위쪽 경로에 접근할 수 있던 문제를 고쳤습니다.

  • 익명 로그인: 클라이언트가 실제로 보내는 anonymousftp를 아이디로 받고, 비밀번호는 검사하지 않습니다.

구현에서 눈여겨본 부분

FTP 프로토콜은 ftpserverlib가 처리하고, 파일 시스템은 afero로 감쌉니다. One FTP Server가 더하는 부분은 사용자 한 명을 인증하는 driver, 실행할 때 만드는 인증서, 그리고 설정 요약 출력입니다.

한 명만 인증하는 driver

Java 버전에서는 Apache FtpServer의 UserManager 인터페이스를 구현해서 아이디와 비밀번호 한 쌍만 검사하는 SingleUserManager를 만들었습니다. Go 버전에서 그 역할을 하는 코드는 internal/oneftpserver/driver.goauthenticate입니다.

driver.go
func (d *driver) authenticate(user, pass string) bool {
	if d.config.Anonymous() {
		// "ftp" is the other name clients traditionally send for an anonymous
		// login, and the password is an e-mail address nobody checks.
		return user == AnonymousID || user == "ftp"
	}

	// Constant time by contract. Go's == makes no such promise: it happens to
	// compare 8 bytes at a time, so a short password gives an attacker nothing
	// to time today, but that is an implementation detail and not a guarantee.
	sameUser := subtle.ConstantTimeCompare([]byte(user), []byte(d.config.ID)) == 1
	samePassword := subtle.ConstantTimeCompare([]byte(pass), []byte(d.config.Password)) == 1

	return sameUser && samePassword
}

== 대신 crypto/subtleConstantTimeCompare를 쓴 이유는 상수 시간 비교를 보장받기 위해서입니다. 비교가 다른 바이트를 만나는 순간 멈추면 걸린 시간에 앞에서 몇 글자가 맞았는지가 드러난다는 것이 상수 시간 비교를 권하는 흔한 이유입니다. 다만 Go의 ==가 실제로 그렇게 새지는 않습니다. 16바이트 문자열을 Go 1.26.7에서 재 보면 불일치 위치가 0~7일 때 3.11ns, 8~15일 때 3.82ns로 두 값뿐이고 완전히 일치할 때는 그 사이인 3.48ns입니다. runtime.memequal이 바이트 단위가 아니라 8바이트씩 한 번에 비교하기 때문에 글자마다 시간이 늘어나는 구간이 없고, 오래 걸렸다고 더 많이 맞은 것도 아닙니다. 원격에서 응답 시간을 측정할 수 있는 한계가 LAN에서 100ns 수준이라는 보고를 감안하면 이 정도 차이는 신호가 되지 못합니다. 그래도 ConstantTimeCompare를 쓰는 이유는 ==가 상수 시간을 약속하지 않는다는 데 있습니다. 길이나 아키텍처, 런타임 구현이 바뀌면 위와 같이 다시 재야 하는데, 보장이 문서에 적힌 API를 쓰면 그럴 일이 없습니다.

로그인을 거절할 때는 아이디가 틀렸는지 비밀번호가 틀렸는지 구분하지 않고 같은 오류를 돌려줍니다.

홈 디렉터리 밖을 감추는 파일 시스템

인증에 성공한 클라이언트에는 홈 디렉터리를 최상위로 하는 파일 시스템을 넘깁니다.

driver.go
fs := afero.NewBasePathFs(afero.NewOsFs(), d.config.Home)
hiding := &pathHidingFs{Fs: fs, home: d.config.Home}

return &loggingFs{Fs: hiding, logger: logger}, nil

afero.NewBasePathFs가 홈 디렉터리 위로 올라가지 못하게 막는 부분입니다. 그런데 이 파일 시스템이 내는 오류 메시지에는 디스크의 전체 경로가 담기고, FTP 라이브러리는 그 메시지를 클라이언트에 그대로 전달합니다. 그래서 fs.gopathHidingFs로 한 번 더 감싸서, 오류에 담긴 경로를 클라이언트가 사용한 경로로 바꿉니다. 그 위에 로그를 남기는 loggingFs를 씌우기 때문에, 기록할 동작을 추가할 자리는 라이브러리가 아니라 이 래퍼입니다.

실행할 때마다 새로 만드는 인증서

tls.goselfSignedTLSConfig는 P-256 키와 자체 서명 인증서를 만들어 tls.Config에 바로 담습니다. 인증서에는 localhost와 그 장비의 IP 주소를 넣습니다. 클라이언트가 어느 인터페이스로 들어오든 접속에 쓴 주소가 인증서와 맞도록 하기 위해서입니다. 파일로 쓰지 않기 때문에 --ssl이 옵션 하나로 끝나고, 서버를 끄고 나면 키가 남지 않습니다.

실제 소켓으로 도는 통합 테스트

옵션 조합이 의도대로 동작하는지 확인하려면 반복 테스트가 필요합니다. Java 버전에서는 Apache Commons Net의 FTPClient로 통합 테스트를 만들었는데, Go 버전에서는 테스트에 필요한 만큼의 RFC 959 명령만 직접 구현한 클라이언트를 씁니다. server_test.go에서 서버를 실제로 띄우고 소켓으로 접속합니다.

server_test.go
func TestAFileCanBeUploaded(t *testing.T) {
	home := t.TempDir()
	summary := startServer(t, &Config{ID: "benelog", Password: "1234", Home: home})

	client := dial(t, summary.Port, false)
	client.login("benelog", "1234")
	client.store("uploaded.txt", "my works")

	stored, err := os.ReadFile(filepath.Join(home, "uploaded.txt"))
	if err != nil {
		t.Fatalf("the uploaded file is not on disk: %v", err)
	}
	if string(stored) != "my works" {
		t.Errorf("stored %q, want %q", stored, "my works")
	}
}

테스트는 모두 --port=0으로 서버를 띄웁니다. 포트를 OS가 고르므로 여러 테스트를 동시에 돌려도 포트가 겹치지 않습니다. FTPS 테스트도 같은 클라이언트를 tls.Dial로 연결해서 씁니다. implicit FTPS라서 AUTH TLS를 주고받는 절차 없이 첫 바이트부터 암호화됩니다.

품질 도구는 Go 코드에 품질 도구 적용하기에 정리한 구성을 그대로 씁니다. make check가 goimports, golangci-lint, 테스트를 차례로 실행하고, make ci는 GitHub Actions에서 push와 pull request마다 도는 명령입니다.

참고 자료

주요 변경이력
  • 2026.09.05

    • Go로 다시 만든 2.x 기준으로 새로 씀

    • ConstantTimeCompare를 쓴 이유를 실측 결과로 다시 씀

  • 2012.05.17

    • Java 버전 소개 글 최초 작성

IEEE 754 부동소수점 오차와 BigDecimal DoltHub에 데이터를 저장하는 애플리케이션 개발